mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 13:54:44 +08:00
初步支持KVM
This commit is contained in:
+1
Submodule .claude/worktrees/agent-ae3871aebda20eb86 added at 422e48b524
@@ -105,10 +105,7 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func listContainers(w http.ResponseWriter, r *http.Request) {
|
||||
containers, err := lxcManager.ListContainers()
|
||||
if err != nil {
|
||||
containers = config.AppConfig.Containers
|
||||
}
|
||||
containers, _ := listByRuntime()
|
||||
containers = filterContainersForRequest(r, containers)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers})
|
||||
}
|
||||
@@ -123,11 +120,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
|
||||
return
|
||||
}
|
||||
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
|
||||
if cfg.TemplateID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
|
||||
return
|
||||
}
|
||||
if !isTemplateEnabledAndDownloaded(cfg.TemplateID) {
|
||||
if !isImageEnabledAndDownloaded(cfg.TemplateID, cfg.Virtualization) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
@@ -150,7 +148,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
if cfg.SnapshotLimit <= 0 {
|
||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||
}
|
||||
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
|
||||
if err := validateRuntimeResourceRequest(cfg.Virtualization, cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -166,7 +164,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := lxcManager.CreateContainer(cfg); err != nil {
|
||||
if err := createByRuntime(cfg); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -183,7 +181,7 @@ func getContainer(w http.ResponseWriter, r *http.Request, id int) {
|
||||
}
|
||||
|
||||
func getUsage(w http.ResponseWriter, r *http.Request, id int) {
|
||||
usage, err := lxcManager.GetResourceUsage(id)
|
||||
usage, err := usageByRuntime(id)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
@@ -192,7 +190,7 @@ func getUsage(w http.ResponseWriter, r *http.Request, id int) {
|
||||
}
|
||||
|
||||
func getTraffic(w http.ResponseWriter, r *http.Request, id int) {
|
||||
info := lxcManager.GetTrafficInfo(id)
|
||||
info := trafficByRuntime(id)
|
||||
if info == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
@@ -281,7 +279,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
if req.RAMMB > 0 {
|
||||
nextRAMMB = req.RAMMB
|
||||
}
|
||||
if err := validateContainerResourceRequest(nextVCPU, nextRAMMB, c.DiskGB); err != nil {
|
||||
if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -294,7 +292,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
|
||||
// Re-apply resource limits to running container
|
||||
if c.Status == "running" {
|
||||
if err := lxcManager.ApplyContainerLimits(c); err != nil {
|
||||
if err := applyLimitsByRuntime(c); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -354,10 +352,7 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
containers, err := lxcManager.ListContainers()
|
||||
if err != nil {
|
||||
containers = config.AppConfig.Containers
|
||||
}
|
||||
containers, _ := listByRuntime()
|
||||
running := 0
|
||||
stopped := 0
|
||||
for _, c := range containers {
|
||||
@@ -391,7 +386,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
|
||||
return
|
||||
}
|
||||
newPassword, err := lxcManager.ResetSSHPassword(id)
|
||||
newPassword, err := resetPasswordByRuntime(id)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/kvm"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
type ImageInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Distro string `json:"distro"`
|
||||
Release string `json:"release"`
|
||||
Arch string `json:"arch"`
|
||||
@@ -78,6 +80,9 @@ func getEnabledImageSet() map[string]bool {
|
||||
for _, t := range lxc.GetTemplates() {
|
||||
set[t.ID] = true
|
||||
}
|
||||
for _, t := range kvm.GetImages() {
|
||||
set[t.ID] = true
|
||||
}
|
||||
} else {
|
||||
for _, id := range config.AppConfig.EnabledImages {
|
||||
set[id] = true
|
||||
@@ -93,16 +98,34 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
templates := lxc.GetTemplates()
|
||||
enabledSet := getEnabledImageSet()
|
||||
|
||||
images := make([]ImageInfo, 0, len(templates))
|
||||
templates := lxc.GetTemplates()
|
||||
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
|
||||
for _, t := range templates {
|
||||
_, downloading := imageDownloads[t.ID]
|
||||
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
||||
images = append(images, ImageInfo{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Type: config.VirtualizationLXC,
|
||||
Distro: t.Distro,
|
||||
Release: t.Release,
|
||||
Arch: t.Arch,
|
||||
Description: t.Description,
|
||||
Downloaded: downloaded,
|
||||
Enabled: enabledSet[t.ID],
|
||||
Downloading: downloading,
|
||||
SizeBytes: size,
|
||||
})
|
||||
}
|
||||
for _, t := range kvm.GetImages() {
|
||||
_, downloading := imageDownloads[t.ID]
|
||||
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
|
||||
images = append(images, ImageInfo{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Type: config.VirtualizationKVM,
|
||||
Distro: t.Distro,
|
||||
Release: t.Release,
|
||||
Arch: t.Arch,
|
||||
@@ -134,7 +157,35 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||
if tmpl == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||
image := kvm.FindImage(req.TemplateID)
|
||||
if image == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||
return
|
||||
}
|
||||
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
||||
ensureImageEnabled(image.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
|
||||
return
|
||||
}
|
||||
imageDownloadsMu.Lock()
|
||||
if imageDownloads[req.TemplateID] {
|
||||
imageDownloadsMu.Unlock()
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
|
||||
return
|
||||
}
|
||||
imageDownloads[req.TemplateID] = true
|
||||
imageDownloadsMu.Unlock()
|
||||
defer func() {
|
||||
imageDownloadsMu.Lock()
|
||||
delete(imageDownloads, req.TemplateID)
|
||||
imageDownloadsMu.Unlock()
|
||||
}()
|
||||
ensureImageEnabled(image.ID)
|
||||
if err := kvm.DownloadImage(*image); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Download failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -206,6 +257,15 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||
if tmpl == nil {
|
||||
if image := kvm.FindImage(req.TemplateID); image != nil {
|
||||
if err := kvm.DeleteImage(image.ID); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + err.Error()})
|
||||
return
|
||||
}
|
||||
removeImageEnabled(image.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||
return
|
||||
}
|
||||
@@ -259,13 +319,27 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
templates := lxc.GetTemplates()
|
||||
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
||||
enabledSet := getEnabledImageSet()
|
||||
|
||||
result := make([]lxc.Template, 0)
|
||||
for _, t := range templates {
|
||||
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
|
||||
result = append(result, t)
|
||||
result := make([]map[string]string, 0)
|
||||
if runtime == config.VirtualizationKVM {
|
||||
for _, t := range kvm.GetImages() {
|
||||
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
|
||||
result = append(result, map[string]string{
|
||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||
"description": t.Description, "type": config.VirtualizationKVM,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, t := range lxc.GetTemplates() {
|
||||
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
|
||||
result = append(result, map[string]string{
|
||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +347,20 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func isTemplateEnabledAndDownloaded(templateID string) bool {
|
||||
return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID))
|
||||
}
|
||||
|
||||
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
||||
runtime = runtimeFromRequest(runtime)
|
||||
if runtime == config.VirtualizationKVM {
|
||||
image := kvm.FindImage(templateID)
|
||||
if image == nil {
|
||||
return false
|
||||
}
|
||||
enabledSet := getEnabledImageSet()
|
||||
downloaded, _ := kvm.ImageDownloadedInfo(image.ID)
|
||||
return enabledSet[image.ID] && downloaded
|
||||
}
|
||||
tmpl := lxc.FindTemplate(templateID)
|
||||
if tmpl == nil {
|
||||
return false
|
||||
@@ -288,6 +376,9 @@ func ensureImageEnabled(id string) {
|
||||
for _, t := range lxc.GetTemplates() {
|
||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||
}
|
||||
for _, t := range kvm.GetImages() {
|
||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||
}
|
||||
config.SaveConfig()
|
||||
return // Already contains all IDs including this one
|
||||
}
|
||||
@@ -313,6 +404,11 @@ func removeImageEnabled(id string) {
|
||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||
}
|
||||
}
|
||||
for _, t := range kvm.GetImages() {
|
||||
if t.ID != id {
|
||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||
}
|
||||
}
|
||||
config.SaveConfig()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c, err := lxcManager.AssignIPv6(id)
|
||||
c, err := assignIPv6ByRuntime(id)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/kvm"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
var kvmManager = kvm.NewManager()
|
||||
|
||||
func runtimeFromRequest(value string) string {
|
||||
return config.NormalizeVirtualization(value)
|
||||
}
|
||||
|
||||
func runtimeFromTemplateID(templateID string) string {
|
||||
if kvm.FindImage(templateID) != nil {
|
||||
return config.VirtualizationKVM
|
||||
}
|
||||
return config.VirtualizationLXC
|
||||
}
|
||||
|
||||
func createByRuntime(cfg lxc.ContainerConfig) error {
|
||||
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
|
||||
if cfg.Virtualization == config.VirtualizationKVM {
|
||||
return kvmManager.CreateContainer(cfg)
|
||||
}
|
||||
return lxcManager.CreateContainer(cfg)
|
||||
}
|
||||
|
||||
func startByRuntime(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.StartContainer(id)
|
||||
}
|
||||
return lxcManager.StartContainer(id)
|
||||
}
|
||||
|
||||
func stopByRuntime(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.StopContainer(id)
|
||||
}
|
||||
return lxcManager.StopContainer(id)
|
||||
}
|
||||
|
||||
func restartByRuntime(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.RestartContainer(id)
|
||||
}
|
||||
return lxcManager.RestartContainer(id)
|
||||
}
|
||||
|
||||
func destroyByRuntime(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.DestroyContainer(id)
|
||||
}
|
||||
return lxcManager.DestroyContainer(id)
|
||||
}
|
||||
|
||||
func reinstallByRuntime(id int, templateID string) error {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.ReinstallContainer(id, templateID)
|
||||
}
|
||||
return lxcManager.ReinstallContainer(id, templateID)
|
||||
}
|
||||
|
||||
func resetPasswordByRuntime(id int) (string, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.ResetSSHPassword(id)
|
||||
}
|
||||
return lxcManager.ResetSSHPassword(id)
|
||||
}
|
||||
|
||||
func assignIPv6ByRuntime(id int) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.AssignIPv6(id)
|
||||
}
|
||||
return lxcManager.AssignIPv6(id)
|
||||
}
|
||||
|
||||
func usageByRuntime(id int) (map[string]interface{}, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.GetResourceUsage(id)
|
||||
}
|
||||
return lxcManager.GetResourceUsage(id)
|
||||
}
|
||||
|
||||
func trafficByRuntime(id int) map[string]interface{} {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.GetTrafficInfo(id)
|
||||
}
|
||||
return lxcManager.GetTrafficInfo(id)
|
||||
}
|
||||
|
||||
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
|
||||
}
|
||||
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
|
||||
}
|
||||
|
||||
func deleteSnapshotByRuntime(snapshotID string) error {
|
||||
snapshot := config.FindSnapshot(snapshotID)
|
||||
if snapshot != nil {
|
||||
if c := config.FindContainer(snapshot.ContainerID); c != nil && c.IsKVM() {
|
||||
return kvmManager.DeleteSnapshot(snapshotID)
|
||||
}
|
||||
if strings.Contains(snapshot.Path, string(os.PathSeparator)+"kvm"+string(os.PathSeparator)) {
|
||||
return kvmManager.DeleteSnapshot(snapshotID)
|
||||
}
|
||||
}
|
||||
return lxcManager.DeleteSnapshot(snapshotID)
|
||||
}
|
||||
|
||||
func restoreSnapshotByRuntime(snapshotID string) error {
|
||||
snapshot := config.FindSnapshot(snapshotID)
|
||||
if snapshot != nil {
|
||||
if c := config.FindContainer(snapshot.ContainerID); c != nil && c.IsKVM() {
|
||||
return kvmManager.RestoreSnapshot(snapshotID)
|
||||
}
|
||||
if strings.Contains(snapshot.Path, string(os.PathSeparator)+"kvm"+string(os.PathSeparator)) {
|
||||
return kvmManager.RestoreSnapshot(snapshotID)
|
||||
}
|
||||
}
|
||||
return lxcManager.RestoreSnapshot(snapshotID)
|
||||
}
|
||||
|
||||
func setSnapshotScheduleByRuntime(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.SetSnapshotSchedule(id, enabled, intervalHours, scheduleTime, createdBy)
|
||||
}
|
||||
return lxcManager.SetSnapshotSchedule(id, enabled, intervalHours, scheduleTime, createdBy)
|
||||
}
|
||||
|
||||
func applyLimitsByRuntime(c *config.Container) error {
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.ApplyContainerLimits(c)
|
||||
}
|
||||
return lxcManager.ApplyContainerLimits(c)
|
||||
}
|
||||
|
||||
func listByRuntime() ([]config.Container, error) {
|
||||
containers, err := lxcManager.ListContainers()
|
||||
if err != nil {
|
||||
containers = config.AppConfig.Containers
|
||||
}
|
||||
containers = kvmManager.ListContainers(containers)
|
||||
return containers, err
|
||||
}
|
||||
|
||||
func validateRuntimeResourceRequest(runtime string, vcpu float64, ramMB int, diskGB int) error {
|
||||
if runtime == config.VirtualizationKVM {
|
||||
if vcpu < 1 || math.Abs(vcpu-math.Round(vcpu)) > 0.000001 {
|
||||
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
|
||||
}
|
||||
}
|
||||
return validateContainerResourceRequest(vcpu, ramMB, diskGB)
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
|
||||
return
|
||||
}
|
||||
}
|
||||
snapshot, err := lxcManager.CreateSnapshot(containerID, user, false, 0)
|
||||
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
@@ -138,7 +138,7 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
|
||||
req.Time = "03:00"
|
||||
}
|
||||
user := requestUser(r)
|
||||
c, err := lxcManager.SetSnapshotSchedule(containerID, req.Enabled, req.IntervalHours, req.Time, user)
|
||||
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
@@ -162,7 +162,7 @@ func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
|
||||
return
|
||||
}
|
||||
user := requestUser(r)
|
||||
if err := lxcManager.DeleteSnapshot(snapshotID); err != nil {
|
||||
if err := deleteSnapshotByRuntime(snapshotID); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
|
||||
return
|
||||
}
|
||||
user := requestUser(r)
|
||||
if err := lxcManager.RestoreSnapshot(snapshotID); err != nil {
|
||||
if err := restoreSnapshotByRuntime(snapshotID); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
+62
-21
@@ -101,16 +101,30 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if c.IP == "" {
|
||||
if ip, err := lxcManager.GetContainerIP(c.LxcName()); err == nil {
|
||||
var ip string
|
||||
var err error
|
||||
if c.IsKVM() {
|
||||
ip, err = kvmManager.GetContainerIP(c.VirshName())
|
||||
} else {
|
||||
ip, err = lxcManager.GetContainerIP(c.LxcName())
|
||||
}
|
||||
if err == nil {
|
||||
c.IP = ip
|
||||
config.SaveConfig()
|
||||
}
|
||||
}
|
||||
if c.IP == "" {
|
||||
if c.IP == "" && !c.IsKVM() {
|
||||
if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" {
|
||||
c.IP = ip
|
||||
}
|
||||
}
|
||||
if c.IP == "" && c.IsKVM() {
|
||||
if err := kvmManager.EnsureSSH(c.ID); err == nil {
|
||||
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||
c = refreshed
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.IP == "" {
|
||||
http.Error(w, "container ip is not available", http.StatusBadRequest)
|
||||
return
|
||||
@@ -127,6 +141,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
defer ws.Close()
|
||||
|
||||
if c.SSHPassword == "" {
|
||||
if c.IsKVM() {
|
||||
writeWebSocketText(ws, nil, "\r\nKVM SSH password is not available. Reinstall or reset after SSH is ready.\r\n")
|
||||
return
|
||||
}
|
||||
writeWebSocketText(ws, nil, "\r\nPreparing SSH service. This can take up to 90 seconds on first boot...\r\n")
|
||||
if err := lxcManager.EnsureSSH(c.ID); err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err))
|
||||
@@ -154,25 +172,48 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr))
|
||||
client, err := ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n")
|
||||
if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
|
||||
return
|
||||
}
|
||||
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||
c = refreshed
|
||||
}
|
||||
if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" {
|
||||
c.IP = ip
|
||||
config.SaveConfig()
|
||||
addr = net.JoinHostPort(c.IP, "22")
|
||||
}
|
||||
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
|
||||
sshConfig.Timeout = 10 * time.Second
|
||||
client, err = ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
|
||||
return
|
||||
if c.IsKVM() {
|
||||
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing KVM guest service. This can take a few minutes on first boot...\r\n")
|
||||
if setupErr := kvmManager.EnsureSSH(c.ID); setupErr != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nKVM SSH auto setup failed: %v\r\n", setupErr))
|
||||
return
|
||||
}
|
||||
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||
c = refreshed
|
||||
}
|
||||
if ip, ipErr := kvmManager.GetContainerIP(c.VirshName()); ipErr == nil && ip != "" {
|
||||
c.IP = ip
|
||||
config.SaveConfig()
|
||||
addr = net.JoinHostPort(c.IP, "22")
|
||||
}
|
||||
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
|
||||
sshConfig.Timeout = 10 * time.Second
|
||||
client, err = ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n")
|
||||
if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
|
||||
return
|
||||
}
|
||||
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||
c = refreshed
|
||||
}
|
||||
if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" {
|
||||
c.IP = ip
|
||||
config.SaveConfig()
|
||||
addr = net.JoinHostPort(c.IP, "22")
|
||||
}
|
||||
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
|
||||
sshConfig.Timeout = 10 * time.Second
|
||||
client, err = ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
@@ -226,7 +226,7 @@ func (q *TaskQueue) createWorker() {
|
||||
c := config.FindContainerByName(task.Config.Name)
|
||||
if c == nil {
|
||||
// 1) Download image + apply limits (lxc-create)
|
||||
err := lxcManager.CreateContainer(task.Config)
|
||||
err := createByRuntime(task.Config)
|
||||
if err != nil {
|
||||
task.Status = "failed"
|
||||
task.Error = err.Error()
|
||||
@@ -256,10 +256,10 @@ func (q *TaskQueue) createWorker() {
|
||||
|
||||
// 3) Start + initialize SSH/network in the same worker.
|
||||
// If init fails, destroy the container so no dead entry remains.
|
||||
startErr := lxcManager.StartContainer(c.ID)
|
||||
startErr := startByRuntime(c.ID)
|
||||
if startErr != nil {
|
||||
if createdByTask {
|
||||
lxcManager.DestroyContainer(c.ID)
|
||||
_ = destroyByRuntime(c.ID)
|
||||
}
|
||||
task.Status = "failed"
|
||||
task.Error = startErr.Error()
|
||||
@@ -304,13 +304,13 @@ func (q *TaskQueue) opWorker() {
|
||||
if err == nil {
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
err = lxcManager.StartContainer(task.ContainerID)
|
||||
err = startByRuntime(task.ContainerID)
|
||||
case TaskStop:
|
||||
err = lxcManager.StopContainer(task.ContainerID)
|
||||
err = stopByRuntime(task.ContainerID)
|
||||
case TaskRestart:
|
||||
err = lxcManager.RestartContainer(task.ContainerID)
|
||||
err = restartByRuntime(task.ContainerID)
|
||||
case TaskDelete:
|
||||
err = lxcManager.DestroyContainer(task.ContainerID)
|
||||
err = destroyByRuntime(task.ContainerID)
|
||||
if err == nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
if config.FindContainer(task.ContainerID) != nil {
|
||||
@@ -318,7 +318,7 @@ func (q *TaskQueue) opWorker() {
|
||||
}
|
||||
}
|
||||
case TaskReinstall:
|
||||
err = lxcManager.ReinstallContainer(task.ContainerID, task.TemplateID)
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +456,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
||||
templateID = c.Template
|
||||
}
|
||||
}
|
||||
if !isTemplateEnabledAndDownloaded(templateID) {
|
||||
runtime := runtimeFromTemplateID(templateID)
|
||||
if c := config.FindContainer(id); c != nil {
|
||||
runtime = c.Runtime()
|
||||
}
|
||||
if !isImageEnabledAndDownloaded(templateID, runtime) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
@@ -516,13 +520,14 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Containers[i].VCPU <= 0 {
|
||||
req.Containers[i].VCPU = 1
|
||||
}
|
||||
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
|
||||
if req.Containers[i].RAMMB < 128 {
|
||||
req.Containers[i].RAMMB = 512
|
||||
}
|
||||
if req.Containers[i].DiskGB < 1 {
|
||||
req.Containers[i].DiskGB = 5
|
||||
}
|
||||
if !isTemplateEnabledAndDownloaded(req.Containers[i].TemplateID) {
|
||||
if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
@@ -532,7 +537,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Containers[i].SnapshotLimit <= 0 {
|
||||
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
|
||||
}
|
||||
if err := validateContainerResourceRequest(req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
|
||||
if err := validateRuntimeResourceRequest(req.Containers[i].Virtualization, req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -73,7 +73,11 @@ type Container struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
LXCName string `json:"lxc_name,omitempty"`
|
||||
KVMName string `json:"kvm_name,omitempty"`
|
||||
DiskImage string `json:"disk_image,omitempty"`
|
||||
MACAddress string `json:"mac_address,omitempty"`
|
||||
Template string `json:"template"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
@@ -109,6 +113,28 @@ type Container struct {
|
||||
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
|
||||
}
|
||||
|
||||
const (
|
||||
VirtualizationLXC = "lxc"
|
||||
VirtualizationKVM = "kvm"
|
||||
)
|
||||
|
||||
func NormalizeVirtualization(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case VirtualizationKVM:
|
||||
return VirtualizationKVM
|
||||
default:
|
||||
return VirtualizationLXC
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Container) Runtime() string {
|
||||
return NormalizeVirtualization(c.Virtualization)
|
||||
}
|
||||
|
||||
func (c *Container) IsKVM() bool {
|
||||
return c.Runtime() == VirtualizationKVM
|
||||
}
|
||||
|
||||
// LxcName returns the internal LXC container name (ct-{id})
|
||||
func (c *Container) LxcName() string {
|
||||
if c.LXCName != "" {
|
||||
@@ -117,6 +143,14 @@ func (c *Container) LxcName() string {
|
||||
return fmt.Sprintf("ct-%d", c.ID)
|
||||
}
|
||||
|
||||
// VirshName returns the internal libvirt domain name for KVM instances.
|
||||
func (c *Container) VirshName() string {
|
||||
if c.KVMName != "" {
|
||||
return c.KVMName
|
||||
}
|
||||
return fmt.Sprintf("vm-%d", c.ID)
|
||||
}
|
||||
|
||||
// SubUser represents a sub-user with access to specific containers
|
||||
type ApiKeyConfig struct {
|
||||
ID string `json:"id"`
|
||||
@@ -343,6 +377,9 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
AppConfig.Oversell.SubUserSnapshotLimit = 3
|
||||
}
|
||||
changed := ensureContainerUUIDs()
|
||||
if ensureContainerVirtualization() {
|
||||
changed = true
|
||||
}
|
||||
if ensureContainerPortMappingLimits() {
|
||||
changed = true
|
||||
}
|
||||
@@ -367,6 +404,18 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
return AppConfig, nil
|
||||
}
|
||||
|
||||
func ensureContainerVirtualization() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.Containers {
|
||||
next := NormalizeVirtualization(AppConfig.Containers[i].Virtualization)
|
||||
if AppConfig.Containers[i].Virtualization != next {
|
||||
AppConfig.Containers[i].Virtualization = next
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func ensureContainerSnapshotScheduleDefaults() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.Containers {
|
||||
@@ -519,6 +568,7 @@ func AddContainer(c Container) {
|
||||
if c.UUID == "" {
|
||||
c.UUID = NewContainerUUID()
|
||||
}
|
||||
c.Virtualization = NormalizeVirtualization(c.Virtualization)
|
||||
AppConfig.Containers = append(AppConfig.Containers, c)
|
||||
SaveConfig()
|
||||
}
|
||||
@@ -792,6 +842,19 @@ func CleanStaleContainers() {
|
||||
valid := make([]Container, 0)
|
||||
changed := false
|
||||
for _, c := range AppConfig.Containers {
|
||||
if c.IsKVM() {
|
||||
if c.DiskImage == "" {
|
||||
valid = append(valid, c)
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(c.DiskImage); os.IsNotExist(err) {
|
||||
fmt.Printf("Cleaning stale KVM config: %s (disk image not found)\n", c.VirshName())
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
valid = append(valid, c)
|
||||
continue
|
||||
}
|
||||
lxcDir := "/var/lib/lxc/" + c.LxcName()
|
||||
if _, err := os.Stat(lxcDir); os.IsNotExist(err) {
|
||||
fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Distro string `json:"distro"`
|
||||
Release string `json:"release"`
|
||||
Arch string `json:"arch"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func GetImages() []Image {
|
||||
return []Image{
|
||||
{
|
||||
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
|
||||
Distro: "ubuntu", Release: "noble", Arch: "amd64",
|
||||
Description: "Ubuntu 24.04 LTS cloud image for KVM",
|
||||
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
|
||||
},
|
||||
{
|
||||
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
|
||||
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
|
||||
Description: "Ubuntu 22.04 LTS cloud image for KVM",
|
||||
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
|
||||
},
|
||||
{
|
||||
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
|
||||
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
||||
Description: "Debian 12 generic cloud image for KVM",
|
||||
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
|
||||
Distro: "debian", Release: "bullseye", Arch: "amd64",
|
||||
Description: "Debian 11 generic cloud image for KVM",
|
||||
URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-amd64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM",
|
||||
Distro: "rockylinux", Release: "9", Arch: "amd64",
|
||||
Description: "Rocky Linux 9 GenericCloud image for KVM",
|
||||
URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-centos-9-stream", Name: "CentOS Stream 9 KVM",
|
||||
Distro: "centos", Release: "9-stream", Arch: "amd64",
|
||||
Description: "CentOS Stream 9 GenericCloud image for KVM",
|
||||
URL: "https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-alpine-3.23", Name: "Alpine 3.23 KVM",
|
||||
Distro: "alpine", Release: "3.23", Arch: "amd64",
|
||||
Description: "Alpine Linux 3.23 NoCloud cloud-init image for KVM",
|
||||
URL: "https://dev.alpinelinux.org/~tomalok/alpine-cloud-images/v3.23/nocloud/x86_64/nocloud_alpine-3.23.4-x86_64-bios-cloudinit-r0.qcow2",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func FindImage(id string) *Image {
|
||||
for _, image := range GetImages() {
|
||||
if image.ID == id {
|
||||
return &image
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CacheDir() string {
|
||||
return filepath.Join(BaseDir(), "images")
|
||||
}
|
||||
|
||||
func ImagePath(id string) string {
|
||||
return filepath.Join(CacheDir(), id+".qcow2")
|
||||
}
|
||||
@@ -15,7 +15,7 @@ func IsExpired(c config.Container) bool {
|
||||
// StopExpiredContainers stops running containers whose expiration date has passed.
|
||||
func (m *Manager) StopExpiredContainers(now time.Time) {
|
||||
for _, container := range config.AppConfig.Containers {
|
||||
if !isContainerExpired(container, now) {
|
||||
if container.IsKVM() || !isContainerExpired(container, now) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (m *Manager) StopTrafficExceededContainers(now time.Time) {
|
||||
saved := false
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if c.Status != "running" {
|
||||
if c.IsKVM() || c.Status != "running" {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,9 @@ func (m *Manager) WarmRunningContainersSSH() {
|
||||
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
||||
for _, container := range containers {
|
||||
c := container
|
||||
if c.IsKVM() {
|
||||
continue
|
||||
}
|
||||
status, err := m.GetContainerStatus(c.LxcName())
|
||||
if err != nil || status != "running" {
|
||||
continue
|
||||
@@ -102,6 +105,11 @@ func (m *Manager) updateAllRates() {
|
||||
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if c.IsKVM() {
|
||||
delete(lastUsage, c.VirshName())
|
||||
delete(rateCache, c.VirshName())
|
||||
continue
|
||||
}
|
||||
if c.Status != "running" {
|
||||
delete(lastUsage, c.LxcName())
|
||||
delete(rateCache, c.LxcName())
|
||||
@@ -211,6 +219,7 @@ func NewManager() *Manager {
|
||||
// ContainerConfig defines container creation parameters
|
||||
type ContainerConfig struct {
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
@@ -345,6 +354,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
ID: id,
|
||||
UUID: config.NewContainerUUID(),
|
||||
Name: cfg.Name,
|
||||
Virtualization: config.VirtualizationLXC,
|
||||
Template: cfg.TemplateID,
|
||||
VCPU: cfg.VCPU,
|
||||
RAMMB: cfg.RAMMB,
|
||||
@@ -1986,6 +1996,9 @@ func (m *Manager) GetContainerIP(lxcName string) (string, error) {
|
||||
func (m *Manager) ListContainers() ([]config.Container, error) {
|
||||
containers := config.AppConfig.Containers
|
||||
for i := range containers {
|
||||
if containers[i].IsKVM() {
|
||||
continue
|
||||
}
|
||||
status, err := m.GetContainerStatus(containers[i].LxcName())
|
||||
if err == nil {
|
||||
containers[i].Status = status
|
||||
@@ -2061,6 +2074,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
||||
UUID: config.NewContainerUUID(),
|
||||
Name: name,
|
||||
LXCName: lxcName,
|
||||
Virtualization: config.VirtualizationLXC,
|
||||
Template: "imported",
|
||||
VCPU: 1,
|
||||
RAMMB: 512,
|
||||
@@ -2579,6 +2593,9 @@ func (m *Manager) AccumulateTraffic() {
|
||||
delete(lastTrafficSnapshot, c.LxcName())
|
||||
continue
|
||||
}
|
||||
if c.IsKVM() {
|
||||
continue
|
||||
}
|
||||
// Reset if new month
|
||||
if c.TrafficResetDate != currentMonth {
|
||||
c.TrafficUsedRX = 0
|
||||
|
||||
@@ -18,8 +18,14 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
return fmt.Errorf("container has no IP")
|
||||
}
|
||||
tag := clicdTag(id)
|
||||
bridge := "lxcbr0"
|
||||
subnet := "10.0.3.0/24"
|
||||
if c.IsKVM() {
|
||||
bridge = "virbr0"
|
||||
subnet = "192.168.122.0/24"
|
||||
}
|
||||
|
||||
EnsureForwardRules()
|
||||
EnsureForwardRules(bridge)
|
||||
m.CleanPortMappings(id)
|
||||
|
||||
for _, pm := range c.PortMappings {
|
||||
@@ -41,8 +47,8 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
|
||||
}
|
||||
|
||||
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
|
||||
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run()
|
||||
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
|
||||
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -50,18 +56,25 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
|
||||
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
||||
|
||||
// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic
|
||||
func EnsureForwardRules() {
|
||||
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
||||
func EnsureForwardRules(bridge string) {
|
||||
if bridge == "" {
|
||||
bridge = "lxcbr0"
|
||||
}
|
||||
rules := [][]string{
|
||||
{"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"},
|
||||
{"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"},
|
||||
{"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"},
|
||||
{"-i", bridge, "-j", "ACCEPT"},
|
||||
{"-o", bridge, "-j", "ACCEPT"},
|
||||
{"-i", bridge, "-o", bridge, "-j", "ACCEPT"},
|
||||
}
|
||||
for _, args := range rules {
|
||||
checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...)
|
||||
if exec.Command("iptables", checkArgs...).Run() != nil {
|
||||
exec.Command("iptables", args...).Run()
|
||||
for {
|
||||
deleteArgs := append([]string{"-D", "FORWARD"}, args...)
|
||||
if exec.Command("iptables", deleteArgs...).Run() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...)
|
||||
exec.Command("iptables", insertArgs...).Run()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ func (m *Manager) runDueSnapshotSchedules() {
|
||||
now := time.Now()
|
||||
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
||||
for _, c := range containers {
|
||||
if !c.SnapshotScheduleEnabled {
|
||||
if c.IsKVM() || !c.SnapshotScheduleEnabled {
|
||||
continue
|
||||
}
|
||||
nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
+11
-5
@@ -9,6 +9,7 @@ import (
|
||||
"clicd/internal/api"
|
||||
"clicd/internal/cli"
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/kvm"
|
||||
"clicd/internal/lxc"
|
||||
"clicd/internal/server"
|
||||
|
||||
@@ -50,18 +51,23 @@ func main() {
|
||||
// Start security scanner
|
||||
api.InitScanner()
|
||||
|
||||
// Ensure iptables FORWARD rules allow LXC traffic
|
||||
lxc.EnsureForwardRules()
|
||||
// Ensure iptables FORWARD rules allow managed bridge traffic.
|
||||
lxc.EnsureForwardRules("lxcbr0")
|
||||
lxc.EnsureForwardRules("virbr0")
|
||||
|
||||
// Start expiry scanner (stops expired containers every 30s)
|
||||
// Start expiry scanners (stops expired/over-traffic workloads every 30s)
|
||||
manager := lxc.NewManager()
|
||||
kvmManager := kvm.NewManager()
|
||||
manager.StartExpiryScanner()
|
||||
kvmManager.StartExpiryScanner()
|
||||
|
||||
// Start usage monitor (computes CPU/network/disk rates every 5s)
|
||||
// Start usage monitors (computes CPU/network/disk rates every 5s)
|
||||
manager.StartUsageMonitor()
|
||||
kvmManager.StartUsageMonitor()
|
||||
|
||||
// Start scheduled snapshot scanner.
|
||||
// Start scheduled snapshot scanners.
|
||||
manager.StartSnapshotScheduler()
|
||||
kvmManager.StartSnapshotScheduler()
|
||||
|
||||
// Clean up stale container configs (LXC dir was deleted but config remains)
|
||||
config.CleanStaleContainers()
|
||||
|
||||
@@ -12,6 +12,7 @@ interface CreateContainerModalProps {
|
||||
|
||||
const defaultForm: CreateContainerRequest = {
|
||||
name: '',
|
||||
virtualization: 'lxc',
|
||||
template_id: '',
|
||||
vcpu: 1,
|
||||
cpu_percent: 100,
|
||||
@@ -43,13 +44,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
getEnabledImages()
|
||||
getEnabledImages(form.virtualization)
|
||||
.then((res) => {
|
||||
const data = res.data.data || []
|
||||
setTemplates(data)
|
||||
if (data.length > 0) {
|
||||
setForm((prev) => ({ ...prev, template_id: prev.template_id || data[0].id }))
|
||||
}
|
||||
setForm((prev) => ({ ...prev, template_id: data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '') }))
|
||||
})
|
||||
.catch(console.error)
|
||||
|
||||
@@ -69,13 +68,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
getHostInfo()
|
||||
.then((res) => setHostInfo(res.data.data || null))
|
||||
.catch(() => setHostInfo(null))
|
||||
}, [isOpen])
|
||||
}, [isOpen, form.virtualization])
|
||||
|
||||
const ipv6Available = !!ipv6Status?.available
|
||||
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
|
||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
||||
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||
|
||||
const autoPorts = useMemo(() => {
|
||||
const count = Math.max(2, form.port_mapping_count)
|
||||
@@ -119,7 +119,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
return
|
||||
}
|
||||
|
||||
const boundedForm = clampCreateForm(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||
if (Object.keys(resourceErrors).length > 0) {
|
||||
dialog.alert('资源配置有误', '请按红色提示修改 vCPU、内存或磁盘配置')
|
||||
return
|
||||
}
|
||||
|
||||
const boundedForm = normalizeCreateForm(form)
|
||||
|
||||
// Build batch of containers
|
||||
const containers: CreateContainerRequest[] = []
|
||||
@@ -181,10 +186,29 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</div>
|
||||
{batchCount > 1 && <p className="text-xs text-gray-400">将创建 {batchCount} 个容器:{form.name}-{batchStartIndex} 至 {form.name}-{batchStartIndex + batchCount - 1}</p>}
|
||||
|
||||
<Field label="虚拟化架构">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, virtualization: 'lxc', template_id: '' }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
LXC 容器
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, virtualization: 'kvm', template_id: '' }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
KVM 虚拟机
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="系统模板">
|
||||
{templates.length === 0 ? (
|
||||
<div className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
|
||||
暂无可用的系统镜像,请先在「镜像管理」中下载镜像模板。
|
||||
暂无可用的{form.virtualization === 'kvm' ? ' KVM' : ' LXC'}系统镜像,请先在「镜像管理」中下载镜像模板。
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
@@ -219,16 +243,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="vCPU">
|
||||
<NumberInput value={form.vcpu} min={0.25} max={maxVCPU} step={0.25} onChange={(value) => setForm({ ...form, vcpu: clampVCPU(value, maxVCPU) })} />
|
||||
<NumberInput
|
||||
value={form.vcpu}
|
||||
min={form.virtualization === 'kvm' ? 1 : 0.25}
|
||||
max={maxVCPU}
|
||||
step={form.virtualization === 'kvm' ? 1 : 0.25}
|
||||
invalid={!!resourceErrors.vcpu}
|
||||
onChange={(value) => setForm({ ...form, vcpu: value })}
|
||||
/>
|
||||
{resourceErrors.vcpu && <p className="mt-1 text-xs text-red-500">{resourceErrors.vcpu}</p>}
|
||||
</Field>
|
||||
<Field label="内存 (MB)">
|
||||
<NumberInput value={form.ram_mb} min={128} max={maxRAMMB} step={128} onChange={(value) => setForm({ ...form, ram_mb: clampInt(value, 128, maxRAMMB, 512) })} />
|
||||
<NumberInput
|
||||
value={form.ram_mb}
|
||||
min={128}
|
||||
max={maxRAMMB}
|
||||
step={128}
|
||||
invalid={!!resourceErrors.ram_mb}
|
||||
onChange={(value) => setForm({ ...form, ram_mb: value })}
|
||||
/>
|
||||
{resourceErrors.ram_mb && <p className="mt-1 text-xs text-red-500">{resourceErrors.ram_mb}</p>}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="磁盘 (GB)">
|
||||
<NumberInput value={form.disk_gb} min={1} max={maxDiskGB} onChange={(value) => setForm({ ...form, disk_gb: clampInt(value, 1, maxDiskGB, 10) })} />
|
||||
<NumberInput
|
||||
value={form.disk_gb}
|
||||
min={1}
|
||||
max={maxDiskGB}
|
||||
invalid={!!resourceErrors.disk_gb}
|
||||
onChange={(value) => setForm({ ...form, disk_gb: value })}
|
||||
/>
|
||||
{resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>}
|
||||
</Field>
|
||||
<Field label="带宽 (Mbps)">
|
||||
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} />
|
||||
@@ -342,44 +389,96 @@ function NumberInput({
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
invalid,
|
||||
onChange,
|
||||
}: {
|
||||
value: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
invalid?: boolean
|
||||
onChange: (value: number) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState(Number.isFinite(value) ? String(value) : '')
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) {
|
||||
setDraft(Number.isFinite(value) ? String(value) : '')
|
||||
}
|
||||
}, [focused, value])
|
||||
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
type="text"
|
||||
inputMode={step && !Number.isInteger(step) ? 'decimal' : 'numeric'}
|
||||
value={draft}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => {
|
||||
setFocused(false)
|
||||
setDraft(Number.isFinite(value) ? String(value) : '')
|
||||
}}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value
|
||||
const value = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
|
||||
onChange(value)
|
||||
setDraft(raw)
|
||||
const next = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
|
||||
onChange(next)
|
||||
}}
|
||||
className={inputClass}
|
||||
aria-invalid={invalid || undefined}
|
||||
data-min={min}
|
||||
data-max={max}
|
||||
data-step={step}
|
||||
className={`${inputClass} ${invalid ? 'border-red-400 focus:border-red-400 focus:ring-red-400' : ''}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number): CreateContainerRequest {
|
||||
function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) {
|
||||
const errors: Partial<Record<'vcpu' | 'ram_mb' | 'disk_gb', string>> = {}
|
||||
const minVCPU = form.virtualization === 'kvm' ? 1 : 0.25
|
||||
|
||||
if (!Number.isFinite(form.vcpu)) {
|
||||
errors.vcpu = '请输入 vCPU'
|
||||
} else if (form.vcpu < minVCPU) {
|
||||
errors.vcpu = `不能小于 ${minVCPU} 核`
|
||||
} else if (form.vcpu > maxVCPU) {
|
||||
errors.vcpu = `不能大于 ${maxVCPU} 核`
|
||||
} else if (form.virtualization === 'kvm' && form.vcpu !== Math.round(form.vcpu)) {
|
||||
errors.vcpu = 'KVM vCPU 必须是整数'
|
||||
}
|
||||
|
||||
if (!Number.isFinite(form.ram_mb)) {
|
||||
errors.ram_mb = '请输入内存'
|
||||
} else if (form.ram_mb < 128) {
|
||||
errors.ram_mb = '不能小于 128 MB'
|
||||
} else if (maxRAMMB && form.ram_mb > maxRAMMB) {
|
||||
errors.ram_mb = `不能大于 ${maxRAMMB} MB`
|
||||
}
|
||||
|
||||
if (!Number.isFinite(form.disk_gb)) {
|
||||
errors.disk_gb = '请输入磁盘'
|
||||
} else if (form.disk_gb < 1) {
|
||||
errors.disk_gb = '不能小于 1 GB'
|
||||
} else if (maxDiskGB && form.disk_gb > maxDiskGB) {
|
||||
errors.disk_gb = `不能大于 ${maxDiskGB} GB`
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
||||
return {
|
||||
...form,
|
||||
vcpu: clampVCPU(form.vcpu, maxVCPU),
|
||||
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512),
|
||||
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10),
|
||||
vcpu: form.virtualization === 'kvm' ? Math.round(form.vcpu) : normalizeLXCvCPU(form.vcpu),
|
||||
ram_mb: Math.round(form.ram_mb),
|
||||
disk_gb: Math.round(form.disk_gb),
|
||||
snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3),
|
||||
}
|
||||
}
|
||||
|
||||
function clampVCPU(value: number, max: number) {
|
||||
function normalizeLXCvCPU(value: number) {
|
||||
const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4
|
||||
return Number(Math.min(Math.max(rounded, 0.25), max).toFixed(2))
|
||||
return Number(rounded.toFixed(2))
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max?: number, fallback = min) {
|
||||
|
||||
@@ -359,7 +359,7 @@ export default function ContainerDetail() {
|
||||
|
||||
const openReinstall = async () => {
|
||||
try {
|
||||
const res = await getEnabledImages()
|
||||
const res = await getEnabledImages(container?.virtualization || 'lxc')
|
||||
if (res.data.data) {
|
||||
setTemplates(res.data.data)
|
||||
setSelectedTemplate(res.data.data[0]?.id || '')
|
||||
@@ -728,6 +728,7 @@ export default function ContainerDetail() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap mt-2">
|
||||
<InfoTag color="blue">系统 {container.template}</InfoTag>
|
||||
<InfoTag color="slate">类型 {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
|
||||
<InfoTag color="emerald">内网 {container.ip || '-'}</InfoTag>
|
||||
<InfoTag color="amber">NAT {mappingCount} 条</InfoTag>
|
||||
<InfoTag color="violet">{publicHost}:{container.ssh_port}</InfoTag>
|
||||
@@ -1395,12 +1396,13 @@ function StatusBadge({ running }: { running: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet'; children: ReactNode }) {
|
||||
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate'; children: ReactNode }) {
|
||||
const classes = {
|
||||
blue: 'bg-blue-50 text-blue-700 border-blue-100',
|
||||
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100',
|
||||
amber: 'bg-amber-50 text-amber-700 border-amber-100',
|
||||
violet: 'bg-violet-50 text-violet-700 border-violet-100',
|
||||
slate: 'bg-slate-50 text-slate-700 border-slate-100',
|
||||
}
|
||||
return <span className={`px-1.5 py-0.5 border rounded text-[11px] whitespace-nowrap ${classes[color]}`}>{children}</span>
|
||||
}
|
||||
@@ -1804,6 +1806,7 @@ function TrafficBar({ container }: { container: Container }) {
|
||||
|
||||
function getTemplateIcon(id: string): ReactNode {
|
||||
const size = 'w-6 h-6'
|
||||
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
||||
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
||||
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
|
||||
|
||||
@@ -48,6 +48,7 @@ export default function Containers() {
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({})
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [systemFilter, setSystemFilter] = useState('all')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -168,12 +169,13 @@ export default function Containers() {
|
||||
const filteredContainers = useMemo(() => {
|
||||
return filterContainers(displayContainers, {
|
||||
search: searchText,
|
||||
type: typeFilter,
|
||||
system: systemFilter,
|
||||
status: statusFilter,
|
||||
taskStatusMap,
|
||||
taskNameMap,
|
||||
})
|
||||
}, [displayContainers, searchText, systemFilter, statusFilter, tasks])
|
||||
}, [displayContainers, searchText, typeFilter, systemFilter, statusFilter, tasks])
|
||||
const totalPages = Math.max(1, Math.ceil(filteredContainers.length / pageSize))
|
||||
const currentPage = Math.min(page, totalPages)
|
||||
const pageStart = (currentPage - 1) * pageSize
|
||||
@@ -185,7 +187,7 @@ export default function Containers() {
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [searchText, systemFilter, statusFilter, pageSize])
|
||||
}, [searchText, typeFilter, systemFilter, statusFilter, pageSize])
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allFilteredSelected) {
|
||||
@@ -235,70 +237,6 @@ export default function Containers() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5">
|
||||
<span className="text-xs text-gray-500 mr-1">{selected.size} 个</span>
|
||||
<button onClick={() => handleBatchAction('start')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'}
|
||||
</button>
|
||||
<button onClick={() => handleBatchAction('stop')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'}
|
||||
</button>
|
||||
<button onClick={() => handleBatchAction('restart')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<RotateCcw className="w-3 h-3" />{batchLoading ? '执行中...' : '重启'}
|
||||
</button>
|
||||
<button onClick={() => handleBatchAction('delete')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 rounded border border-red-200 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Trash2 className="w-3 h-3" />{batchLoading ? '执行中...' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{displayContainers.length > 0 && (
|
||||
<>
|
||||
<div className="relative w-[260px]">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
value={searchText}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
className="h-8 w-full rounded-md border border-gray-300 bg-white pl-8 pr-2 text-xs text-black outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="搜索名称、ID、UUID、IP"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={systemFilter}
|
||||
onChange={(event) => setSystemFilter(event.target.value)}
|
||||
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||
title="系统筛选"
|
||||
>
|
||||
<option value="all">全部系统</option>
|
||||
{systemOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||
title="状态筛选"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="running">在线</option>
|
||||
<option value="stopped">离线</option>
|
||||
<option value="task">任务中</option>
|
||||
<option value="creating">创建中</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(event) => setPageSize(Number(event.target.value))}
|
||||
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||
title="每页数量"
|
||||
>
|
||||
<option value={10}>10 / 页</option>
|
||||
<option value={20}>20 / 页</option>
|
||||
<option value={50}>50 / 页</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRefreshList}
|
||||
disabled={refreshing}
|
||||
@@ -332,6 +270,84 @@ export default function Containers() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displayContainers.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-[260px]">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
value={searchText}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
className="h-8 w-full rounded-md border border-gray-300 bg-white pl-8 pr-2 text-xs text-black outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||
placeholder="搜索名称、ID、UUID、IP"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(event) => setTypeFilter(event.target.value)}
|
||||
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||
title="类型筛选"
|
||||
>
|
||||
<option value="all">全部类型</option>
|
||||
<option value="lxc">LXC</option>
|
||||
<option value="kvm">KVM</option>
|
||||
</select>
|
||||
<select
|
||||
value={systemFilter}
|
||||
onChange={(event) => setSystemFilter(event.target.value)}
|
||||
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||
title="系统筛选"
|
||||
>
|
||||
<option value="all">全部系统</option>
|
||||
{systemOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||
title="状态筛选"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="running">在线</option>
|
||||
<option value="stopped">离线</option>
|
||||
<option value="task">任务中</option>
|
||||
<option value="creating">创建中</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(event) => setPageSize(Number(event.target.value))}
|
||||
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||
title="每页数量"
|
||||
>
|
||||
<option value={10}>10 / 页</option>
|
||||
<option value={20}>20 / 页</option>
|
||||
<option value={50}>50 / 页</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<span className="text-xs text-gray-500">{selected.size} 个</span>
|
||||
<button onClick={() => handleBatchAction('start')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'}
|
||||
</button>
|
||||
<button onClick={() => handleBatchAction('stop')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'}
|
||||
</button>
|
||||
<button onClick={() => handleBatchAction('restart')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<RotateCcw className="w-3 h-3" />{batchLoading ? '执行中...' : '重启'}
|
||||
</button>
|
||||
<button onClick={() => handleBatchAction('delete')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-red-600 hover:bg-red-50 rounded border border-red-200 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Trash2 className="w-3 h-3" />{batchLoading ? '执行中...' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayContainers.length === 0 ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-12 text-center">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
@@ -343,7 +359,7 @@ export default function Containers() {
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1200px]">
|
||||
<table className="w-full min-w-[1260px]">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="w-10 px-3 py-3">
|
||||
@@ -361,6 +377,7 @@ export default function Containers() {
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>系统</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead icon><Cpu className="w-3.5 h-3.5" />CPU</TableHead>
|
||||
<TableHead icon><MemoryStick className="w-3.5 h-3.5" />MEMORY</TableHead>
|
||||
<TableHead icon><HardDrive className="w-3.5 h-3.5" />DISK</TableHead>
|
||||
@@ -421,6 +438,9 @@ export default function Containers() {
|
||||
{getTemplateName(container.template)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2.5 py-2 align-top">
|
||||
<RuntimeBadge runtime={container.virtualization || 'lxc'} />
|
||||
</td>
|
||||
<td className="px-2.5 py-2 align-top">
|
||||
<ProgressCell pct={cpuPct} />
|
||||
</td>
|
||||
@@ -612,6 +632,15 @@ function StatusBadge({ running, task, placeholder }: { running: boolean; task?:
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimeBadge({ runtime }: { runtime: string }) {
|
||||
const normalized = runtime === 'kvm' ? 'kvm' : 'lxc'
|
||||
return (
|
||||
<span className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${normalized === 'kvm' ? 'bg-indigo-50 text-indigo-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||
{normalized.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function buildDisplayContainers(
|
||||
containers: Container[],
|
||||
queuedCreates: Record<string, CreateContainerRequest>,
|
||||
@@ -643,6 +672,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
id: 0,
|
||||
uuid: '',
|
||||
name: cfg.name,
|
||||
virtualization: cfg.virtualization || 'lxc',
|
||||
template: cfg.template_id,
|
||||
vcpu: cfg.vcpu,
|
||||
ram_mb: cfg.ram_mb,
|
||||
@@ -715,6 +745,7 @@ function hasActiveTasks(tasks: Task[]) {
|
||||
|
||||
type ContainerFilters = {
|
||||
search: string
|
||||
type: string
|
||||
system: string
|
||||
status: string
|
||||
taskStatusMap: Record<number, Task>
|
||||
@@ -728,6 +759,9 @@ function filterContainers(containers: DisplayContainer[], filters: ContainerFilt
|
||||
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
|
||||
return false
|
||||
}
|
||||
if (filters.type !== 'all' && (container.virtualization || 'lxc') !== filters.type) {
|
||||
return false
|
||||
}
|
||||
if (filters.status !== 'all' && getContainerStatusFilterValue(container, task) !== filters.status) {
|
||||
return false
|
||||
}
|
||||
@@ -740,6 +774,7 @@ function filterContainers(containers: DisplayContainer[], filters: ContainerFilt
|
||||
container.ip,
|
||||
container.ipv6,
|
||||
container.template,
|
||||
container.virtualization || 'lxc',
|
||||
getTemplateName(container.template),
|
||||
getSystemFilterLabel(getSystemFilterValue(container.template)),
|
||||
String(container.ssh_port || ''),
|
||||
@@ -760,14 +795,15 @@ function buildSystemOptions(containers: DisplayContainer[]) {
|
||||
}
|
||||
|
||||
function getSystemFilterValue(template: string) {
|
||||
if (template.startsWith('ubuntu')) return 'ubuntu'
|
||||
if (template.startsWith('debian')) return 'debian'
|
||||
if (template.startsWith('alpine')) return 'alpine'
|
||||
if (template.startsWith('centos')) return 'centos'
|
||||
if (template.startsWith('archlinux')) return 'archlinux'
|
||||
if (template.startsWith('fedora')) return 'fedora'
|
||||
if (template.startsWith('rockylinux')) return 'rockylinux'
|
||||
return template || 'unknown'
|
||||
const normalized = template.startsWith('kvm-') ? template.slice(4) : template
|
||||
if (normalized.startsWith('ubuntu')) return 'ubuntu'
|
||||
if (normalized.startsWith('debian')) return 'debian'
|
||||
if (normalized.startsWith('alpine')) return 'alpine'
|
||||
if (normalized.startsWith('centos')) return 'centos'
|
||||
if (normalized.startsWith('archlinux')) return 'archlinux'
|
||||
if (normalized.startsWith('fedora')) return 'fedora'
|
||||
if (normalized.startsWith('rockylinux')) return 'rockylinux'
|
||||
return normalized || 'unknown'
|
||||
}
|
||||
|
||||
function getSystemFilterLabel(system: string) {
|
||||
@@ -903,12 +939,18 @@ function getTemplateName(id: string) {
|
||||
'archlinux-current': 'Arch Linux',
|
||||
'fedora-44': 'Fedora 44',
|
||||
'rockylinux-10': 'Rocky 10',
|
||||
'kvm-ubuntu-noble': 'Ubuntu 24.04',
|
||||
'kvm-ubuntu-jammy': 'Ubuntu 22.04',
|
||||
'kvm-debian-bookworm': 'Debian 12',
|
||||
'kvm-debian-bullseye': 'Debian 11',
|
||||
'kvm-rockylinux-9': 'Rocky 9',
|
||||
}
|
||||
return map[id] || id
|
||||
}
|
||||
|
||||
function getTemplateIcon(id: string): ReactNode {
|
||||
const size = 'w-4 h-4'
|
||||
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
||||
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
||||
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function ImageManagement() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
|
||||
const fetchImages = useCallback(async () => {
|
||||
try {
|
||||
@@ -80,6 +81,7 @@ export default function ImageManagement() {
|
||||
}
|
||||
|
||||
const downloadedCount = images.filter((img) => img.downloaded).length
|
||||
const visibleImages = images.filter((img) => typeFilter === 'all' || img.type === typeFilter)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -99,13 +101,24 @@ export default function ImageManagement() {
|
||||
已下载 {downloadedCount}/{images.length}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchImages}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
刷新
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(event) => setTypeFilter(event.target.value)}
|
||||
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||
>
|
||||
<option value="all">全部类型</option>
|
||||
<option value="lxc">LXC</option>
|
||||
<option value="kvm">KVM</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchImages}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -126,6 +139,9 @@ export default function ImageManagement() {
|
||||
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
|
||||
发行版
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
|
||||
类型
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
|
||||
架构
|
||||
</th>
|
||||
@@ -141,7 +157,7 @@ export default function ImageManagement() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{images.map((img) => {
|
||||
{visibleImages.map((img) => {
|
||||
const isBusy = actionLoading === img.id
|
||||
return (
|
||||
<tr key={img.id} className="hover:bg-gray-50 transition-colors">
|
||||
@@ -159,6 +175,11 @@ export default function ImageManagement() {
|
||||
<td className="px-4 py-3 text-xs text-gray-600 font-mono">
|
||||
{img.distro} {img.release}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${img.type === 'kvm' ? 'bg-indigo-50 text-indigo-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||
{(img.type || 'lxc').toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500 font-mono">
|
||||
{img.arch}
|
||||
</td>
|
||||
@@ -264,6 +285,7 @@ function StatusBadge({ img }: { img: ImageInfo }) {
|
||||
|
||||
function getTemplateIcon(id: string): ReactNode {
|
||||
const size = 'w-5 h-5'
|
||||
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
||||
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
||||
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface Container {
|
||||
id: number
|
||||
uuid: string
|
||||
name: string
|
||||
virtualization?: string
|
||||
template: string
|
||||
vcpu: number
|
||||
ram_mb: number
|
||||
@@ -85,6 +86,7 @@ export interface Container {
|
||||
export interface Template {
|
||||
id: string
|
||||
name: string
|
||||
type?: string
|
||||
distro: string
|
||||
release: string
|
||||
arch: string
|
||||
@@ -94,6 +96,7 @@ export interface Template {
|
||||
|
||||
export interface CreateContainerRequest {
|
||||
name: string
|
||||
virtualization: string
|
||||
template_id: string
|
||||
vcpu: number
|
||||
cpu_percent: number
|
||||
@@ -338,6 +341,7 @@ export const getTemplates = () =>
|
||||
export interface ImageInfo {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
distro: string
|
||||
release: string
|
||||
arch: string
|
||||
@@ -352,7 +356,7 @@ export const getImages = () =>
|
||||
api.get<APIResponse<ImageInfo[]>>('/images')
|
||||
|
||||
export const downloadImage = (templateId: string) =>
|
||||
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 600000 }) // 10min timeout
|
||||
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 1800000 }) // 30min timeout
|
||||
|
||||
export const deleteImage = (templateId: string) =>
|
||||
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
|
||||
@@ -360,8 +364,8 @@ export const deleteImage = (templateId: string) =>
|
||||
export const toggleImage = (templateId: string, enabled: boolean) =>
|
||||
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
|
||||
|
||||
export const getEnabledImages = () =>
|
||||
api.get<APIResponse<Template[]>>('/images/enabled')
|
||||
export const getEnabledImages = (virtualization = 'lxc') =>
|
||||
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } })
|
||||
|
||||
// Dashboard
|
||||
export const getDashboard = () =>
|
||||
|
||||
Reference in New Issue
Block a user