初步支持KVM

This commit is contained in:
MengMengCode
2026-06-07 09:24:09 +08:00
parent 422e48b524
commit 6dd7079e23
22 changed files with 2986 additions and 192 deletions
Submodule .claude/worktrees/agent-ae3871aebda20eb86 added at 422e48b524
+11 -16
View File
@@ -105,10 +105,7 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
} }
func listContainers(w http.ResponseWriter, r *http.Request) { func listContainers(w http.ResponseWriter, r *http.Request) {
containers, err := lxcManager.ListContainers() containers, _ := listByRuntime()
if err != nil {
containers = config.AppConfig.Containers
}
containers = filterContainersForRequest(r, containers) containers = filterContainersForRequest(r, containers)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: 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"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
return return
} }
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
if cfg.TemplateID == "" { if cfg.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
return 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"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return return
} }
@@ -150,7 +148,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
if cfg.SnapshotLimit <= 0 { if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit 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()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return 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()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return 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) { func getUsage(w http.ResponseWriter, r *http.Request, id int) {
usage, err := lxcManager.GetResourceUsage(id) usage, err := usageByRuntime(id)
if err != nil { if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return 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) { func getTraffic(w http.ResponseWriter, r *http.Request, id int) {
info := lxcManager.GetTrafficInfo(id) info := trafficByRuntime(id)
if info == nil { if info == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return return
@@ -281,7 +279,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
if req.RAMMB > 0 { if req.RAMMB > 0 {
nextRAMMB = req.RAMMB 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()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return return
} }
@@ -294,7 +292,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
// Re-apply resource limits to running container // Re-apply resource limits to running container
if c.Status == "running" { 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()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
} }
@@ -354,10 +352,7 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
containers, err := lxcManager.ListContainers() containers, _ := listByRuntime()
if err != nil {
containers = config.AppConfig.Containers
}
running := 0 running := 0
stopped := 0 stopped := 0
for _, c := range containers { 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: "容器已到期,不允许此操作"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
return return
} }
newPassword, err := lxcManager.ResetSSHPassword(id) newPassword, err := resetPasswordByRuntime(id)
if err != nil { if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
+102 -6
View File
@@ -10,6 +10,7 @@ import (
"sync" "sync"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/kvm"
"clicd/internal/lxc" "clicd/internal/lxc"
) )
@@ -17,6 +18,7 @@ import (
type ImageInfo struct { type ImageInfo struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"`
Distro string `json:"distro"` Distro string `json:"distro"`
Release string `json:"release"` Release string `json:"release"`
Arch string `json:"arch"` Arch string `json:"arch"`
@@ -78,6 +80,9 @@ func getEnabledImageSet() map[string]bool {
for _, t := range lxc.GetTemplates() { for _, t := range lxc.GetTemplates() {
set[t.ID] = true set[t.ID] = true
} }
for _, t := range kvm.GetImages() {
set[t.ID] = true
}
} else { } else {
for _, id := range config.AppConfig.EnabledImages { for _, id := range config.AppConfig.EnabledImages {
set[id] = true set[id] = true
@@ -93,16 +98,34 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
return return
} }
templates := lxc.GetTemplates()
enabledSet := getEnabledImageSet() enabledSet := getEnabledImageSet()
images := make([]ImageInfo, 0, len(templates)) templates := lxc.GetTemplates()
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
for _, t := range templates { for _, t := range templates {
_, downloading := imageDownloads[t.ID] _, downloading := imageDownloads[t.ID]
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch) downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
images = append(images, ImageInfo{ images = append(images, ImageInfo{
ID: t.ID, ID: t.ID,
Name: t.Name, 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, Distro: t.Distro,
Release: t.Release, Release: t.Release,
Arch: t.Arch, Arch: t.Arch,
@@ -134,9 +157,37 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
tmpl := lxc.FindTemplate(req.TemplateID) tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil { if tmpl == nil {
image := kvm.FindImage(req.TemplateID)
if image == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return 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
}
// Already downloaded? Just enable if needed. // Already downloaded? Just enable if needed.
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) { if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
@@ -206,6 +257,15 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
tmpl := lxc.FindTemplate(req.TemplateID) tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil { 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"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return return
} }
@@ -259,13 +319,27 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
return return
} }
templates := lxc.GetTemplates() runtime := runtimeFromRequest(r.URL.Query().Get("type"))
enabledSet := getEnabledImageSet() enabledSet := getEnabledImageSet()
result := make([]lxc.Template, 0) result := make([]map[string]string, 0)
for _, t := range templates { 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) { if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
result = append(result, t) 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 { 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) tmpl := lxc.FindTemplate(templateID)
if tmpl == nil { if tmpl == nil {
return false return false
@@ -288,6 +376,9 @@ func ensureImageEnabled(id string) {
for _, t := range lxc.GetTemplates() { for _, t := range lxc.GetTemplates() {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID) 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() config.SaveConfig()
return // Already contains all IDs including this one 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) 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() config.SaveConfig()
return return
} }
+1 -1
View File
@@ -12,7 +12,7 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
} }
func assignIPv6(w http.ResponseWriter, r *http.Request, id int) { func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
c, err := lxcManager.AssignIPv6(id) c, err := assignIPv6ByRuntime(id)
if err != nil { if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return return
+172
View File
@@ -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)
}
+4 -4
View File
@@ -74,7 +74,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
return return
} }
} }
snapshot, err := lxcManager.CreateSnapshot(containerID, user, false, 0) snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
if err != nil { if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
@@ -138,7 +138,7 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
req.Time = "03:00" req.Time = "03:00"
} }
user := requestUser(r) 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 { if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return return
@@ -162,7 +162,7 @@ func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
return return
} }
user := requestUser(r) 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()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
} }
@@ -177,7 +177,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
return return
} }
user := requestUser(r) 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()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
} }
+43 -2
View File
@@ -101,16 +101,30 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
return return
} }
if c.IP == "" { 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 c.IP = ip
config.SaveConfig() config.SaveConfig()
} }
} }
if c.IP == "" { if c.IP == "" && !c.IsKVM() {
if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" { if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" {
c.IP = 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 == "" { if c.IP == "" {
http.Error(w, "container ip is not available", http.StatusBadRequest) http.Error(w, "container ip is not available", http.StatusBadRequest)
return return
@@ -127,6 +141,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
defer ws.Close() defer ws.Close()
if c.SSHPassword == "" { 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") 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 { if err := lxcManager.EnsureSSH(c.ID); err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err)) writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err))
@@ -154,6 +172,28 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr)) writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr))
client, err := ssh.Dial("tcp", addr, sshConfig) client, err := ssh.Dial("tcp", addr, sshConfig)
if err != nil { if err != nil {
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") 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 { if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr)) writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
@@ -175,6 +215,7 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
}
defer client.Close() defer client.Close()
session, err := client.NewSession() session, err := client.NewSession()
+16 -11
View File
@@ -226,7 +226,7 @@ func (q *TaskQueue) createWorker() {
c := config.FindContainerByName(task.Config.Name) c := config.FindContainerByName(task.Config.Name)
if c == nil { if c == nil {
// 1) Download image + apply limits (lxc-create) // 1) Download image + apply limits (lxc-create)
err := lxcManager.CreateContainer(task.Config) err := createByRuntime(task.Config)
if err != nil { if err != nil {
task.Status = "failed" task.Status = "failed"
task.Error = err.Error() task.Error = err.Error()
@@ -256,10 +256,10 @@ func (q *TaskQueue) createWorker() {
// 3) Start + initialize SSH/network in the same worker. // 3) Start + initialize SSH/network in the same worker.
// If init fails, destroy the container so no dead entry remains. // If init fails, destroy the container so no dead entry remains.
startErr := lxcManager.StartContainer(c.ID) startErr := startByRuntime(c.ID)
if startErr != nil { if startErr != nil {
if createdByTask { if createdByTask {
lxcManager.DestroyContainer(c.ID) _ = destroyByRuntime(c.ID)
} }
task.Status = "failed" task.Status = "failed"
task.Error = startErr.Error() task.Error = startErr.Error()
@@ -304,13 +304,13 @@ func (q *TaskQueue) opWorker() {
if err == nil { if err == nil {
switch task.Type { switch task.Type {
case TaskStart: case TaskStart:
err = lxcManager.StartContainer(task.ContainerID) err = startByRuntime(task.ContainerID)
case TaskStop: case TaskStop:
err = lxcManager.StopContainer(task.ContainerID) err = stopByRuntime(task.ContainerID)
case TaskRestart: case TaskRestart:
err = lxcManager.RestartContainer(task.ContainerID) err = restartByRuntime(task.ContainerID)
case TaskDelete: case TaskDelete:
err = lxcManager.DestroyContainer(task.ContainerID) err = destroyByRuntime(task.ContainerID)
if err == nil { if err == nil {
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
if config.FindContainer(task.ContainerID) != nil { if config.FindContainer(task.ContainerID) != nil {
@@ -318,7 +318,7 @@ func (q *TaskQueue) opWorker() {
} }
} }
case TaskReinstall: 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 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"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return return
} }
@@ -516,13 +520,14 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].VCPU <= 0 { if req.Containers[i].VCPU <= 0 {
req.Containers[i].VCPU = 1 req.Containers[i].VCPU = 1
} }
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
if req.Containers[i].RAMMB < 128 { if req.Containers[i].RAMMB < 128 {
req.Containers[i].RAMMB = 512 req.Containers[i].RAMMB = 512
} }
if req.Containers[i].DiskGB < 1 { if req.Containers[i].DiskGB < 1 {
req.Containers[i].DiskGB = 5 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"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
return return
} }
@@ -532,7 +537,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].SnapshotLimit <= 0 { if req.Containers[i].SnapshotLimit <= 0 {
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit 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()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return return
} }
+63
View File
@@ -73,7 +73,11 @@ type Container struct {
ID int `json:"id"` ID int `json:"id"`
UUID string `json:"uuid"` UUID string `json:"uuid"`
Name string `json:"name"` Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
LXCName string `json:"lxc_name,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"` Template string `json:"template"`
VCPU float64 `json:"vcpu"` VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"` RAMMB int `json:"ram_mb"`
@@ -109,6 +113,28 @@ type Container struct {
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"` 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}) // LxcName returns the internal LXC container name (ct-{id})
func (c *Container) LxcName() string { func (c *Container) LxcName() string {
if c.LXCName != "" { if c.LXCName != "" {
@@ -117,6 +143,14 @@ func (c *Container) LxcName() string {
return fmt.Sprintf("ct-%d", c.ID) 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 // SubUser represents a sub-user with access to specific containers
type ApiKeyConfig struct { type ApiKeyConfig struct {
ID string `json:"id"` ID string `json:"id"`
@@ -343,6 +377,9 @@ func InitConfig() (*ClicdConfig, error) {
AppConfig.Oversell.SubUserSnapshotLimit = 3 AppConfig.Oversell.SubUserSnapshotLimit = 3
} }
changed := ensureContainerUUIDs() changed := ensureContainerUUIDs()
if ensureContainerVirtualization() {
changed = true
}
if ensureContainerPortMappingLimits() { if ensureContainerPortMappingLimits() {
changed = true changed = true
} }
@@ -367,6 +404,18 @@ func InitConfig() (*ClicdConfig, error) {
return AppConfig, nil 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 { func ensureContainerSnapshotScheduleDefaults() bool {
changed := false changed := false
for i := range AppConfig.Containers { for i := range AppConfig.Containers {
@@ -519,6 +568,7 @@ func AddContainer(c Container) {
if c.UUID == "" { if c.UUID == "" {
c.UUID = NewContainerUUID() c.UUID = NewContainerUUID()
} }
c.Virtualization = NormalizeVirtualization(c.Virtualization)
AppConfig.Containers = append(AppConfig.Containers, c) AppConfig.Containers = append(AppConfig.Containers, c)
SaveConfig() SaveConfig()
} }
@@ -792,6 +842,19 @@ func CleanStaleContainers() {
valid := make([]Container, 0) valid := make([]Container, 0)
changed := false changed := false
for _, c := range AppConfig.Containers { 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() lxcDir := "/var/lib/lxc/" + c.LxcName()
if _, err := os.Stat(lxcDir); os.IsNotExist(err) { if _, err := os.Stat(lxcDir); os.IsNotExist(err) {
fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName()) fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName())
File diff suppressed because it is too large Load Diff
+79
View File
@@ -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")
}
+2 -2
View File
@@ -15,7 +15,7 @@ func IsExpired(c config.Container) bool {
// StopExpiredContainers stops running containers whose expiration date has passed. // StopExpiredContainers stops running containers whose expiration date has passed.
func (m *Manager) StopExpiredContainers(now time.Time) { func (m *Manager) StopExpiredContainers(now time.Time) {
for _, container := range config.AppConfig.Containers { for _, container := range config.AppConfig.Containers {
if !isContainerExpired(container, now) { if container.IsKVM() || !isContainerExpired(container, now) {
continue continue
} }
@@ -53,7 +53,7 @@ func (m *Manager) StopTrafficExceededContainers(now time.Time) {
saved := false saved := false
for i := range config.AppConfig.Containers { for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i] c := &config.AppConfig.Containers[i]
if c.Status != "running" { if c.IsKVM() || c.Status != "running" {
continue continue
} }
+17
View File
@@ -73,6 +73,9 @@ func (m *Manager) WarmRunningContainersSSH() {
containers := append([]config.Container(nil), config.AppConfig.Containers...) containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, container := range containers { for _, container := range containers {
c := container c := container
if c.IsKVM() {
continue
}
status, err := m.GetContainerStatus(c.LxcName()) status, err := m.GetContainerStatus(c.LxcName())
if err != nil || status != "running" { if err != nil || status != "running" {
continue continue
@@ -102,6 +105,11 @@ func (m *Manager) updateAllRates() {
for i := range config.AppConfig.Containers { for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i] c := &config.AppConfig.Containers[i]
if c.IsKVM() {
delete(lastUsage, c.VirshName())
delete(rateCache, c.VirshName())
continue
}
if c.Status != "running" { if c.Status != "running" {
delete(lastUsage, c.LxcName()) delete(lastUsage, c.LxcName())
delete(rateCache, c.LxcName()) delete(rateCache, c.LxcName())
@@ -211,6 +219,7 @@ func NewManager() *Manager {
// ContainerConfig defines container creation parameters // ContainerConfig defines container creation parameters
type ContainerConfig struct { type ContainerConfig struct {
Name string `json:"name"` Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"` TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"` VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"` CPUPercent int `json:"cpu_percent"`
@@ -345,6 +354,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
ID: id, ID: id,
UUID: config.NewContainerUUID(), UUID: config.NewContainerUUID(),
Name: cfg.Name, Name: cfg.Name,
Virtualization: config.VirtualizationLXC,
Template: cfg.TemplateID, Template: cfg.TemplateID,
VCPU: cfg.VCPU, VCPU: cfg.VCPU,
RAMMB: cfg.RAMMB, RAMMB: cfg.RAMMB,
@@ -1986,6 +1996,9 @@ func (m *Manager) GetContainerIP(lxcName string) (string, error) {
func (m *Manager) ListContainers() ([]config.Container, error) { func (m *Manager) ListContainers() ([]config.Container, error) {
containers := config.AppConfig.Containers containers := config.AppConfig.Containers
for i := range containers { for i := range containers {
if containers[i].IsKVM() {
continue
}
status, err := m.GetContainerStatus(containers[i].LxcName()) status, err := m.GetContainerStatus(containers[i].LxcName())
if err == nil { if err == nil {
containers[i].Status = status containers[i].Status = status
@@ -2061,6 +2074,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
UUID: config.NewContainerUUID(), UUID: config.NewContainerUUID(),
Name: name, Name: name,
LXCName: lxcName, LXCName: lxcName,
Virtualization: config.VirtualizationLXC,
Template: "imported", Template: "imported",
VCPU: 1, VCPU: 1,
RAMMB: 512, RAMMB: 512,
@@ -2579,6 +2593,9 @@ func (m *Manager) AccumulateTraffic() {
delete(lastTrafficSnapshot, c.LxcName()) delete(lastTrafficSnapshot, c.LxcName())
continue continue
} }
if c.IsKVM() {
continue
}
// Reset if new month // Reset if new month
if c.TrafficResetDate != currentMonth { if c.TrafficResetDate != currentMonth {
c.TrafficUsedRX = 0 c.TrafficUsedRX = 0
+24 -11
View File
@@ -18,8 +18,14 @@ func (m *Manager) ApplyPortMappings(id int) error {
return fmt.Errorf("container has no IP") return fmt.Errorf("container has no IP")
} }
tag := clicdTag(id) 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) m.CleanPortMappings(id)
for _, pm := range c.PortMappings { 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) 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 { 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", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
} }
return nil return nil
@@ -50,19 +56,26 @@ func (m *Manager) ApplyPortMappings(id int) error {
func clicdTag(id int) string { return "c" + strconv.Itoa(id) } func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic // EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
func EnsureForwardRules() { func EnsureForwardRules(bridge string) {
if bridge == "" {
bridge = "lxcbr0"
}
rules := [][]string{ rules := [][]string{
{"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"}, {"-i", bridge, "-j", "ACCEPT"},
{"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"}, {"-o", bridge, "-j", "ACCEPT"},
{"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"}, {"-i", bridge, "-o", bridge, "-j", "ACCEPT"},
} }
for _, args := range rules { for _, args := range rules {
checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...) for {
if exec.Command("iptables", checkArgs...).Run() != nil { deleteArgs := append([]string{"-D", "FORWARD"}, args...)
exec.Command("iptables", args...).Run() if exec.Command("iptables", deleteArgs...).Run() != nil {
break
} }
} }
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...)
exec.Command("iptables", insertArgs...).Run()
}
} }
// CleanPortMappings removes all iptables rules for a container // CleanPortMappings removes all iptables rules for a container
+1 -1
View File
@@ -207,7 +207,7 @@ func (m *Manager) runDueSnapshotSchedules() {
now := time.Now() now := time.Now()
containers := append([]config.Container(nil), config.AppConfig.Containers...) containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, c := range containers { for _, c := range containers {
if !c.SnapshotScheduleEnabled { if c.IsKVM() || !c.SnapshotScheduleEnabled {
continue continue
} }
nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun) nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
+1
View File
@@ -0,0 +1 @@
+11 -5
View File
@@ -9,6 +9,7 @@ import (
"clicd/internal/api" "clicd/internal/api"
"clicd/internal/cli" "clicd/internal/cli"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/kvm"
"clicd/internal/lxc" "clicd/internal/lxc"
"clicd/internal/server" "clicd/internal/server"
@@ -50,18 +51,23 @@ func main() {
// Start security scanner // Start security scanner
api.InitScanner() api.InitScanner()
// Ensure iptables FORWARD rules allow LXC traffic // Ensure iptables FORWARD rules allow managed bridge traffic.
lxc.EnsureForwardRules() 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() manager := lxc.NewManager()
kvmManager := kvm.NewManager()
manager.StartExpiryScanner() 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() manager.StartUsageMonitor()
kvmManager.StartUsageMonitor()
// Start scheduled snapshot scanner. // Start scheduled snapshot scanners.
manager.StartSnapshotScheduler() manager.StartSnapshotScheduler()
kvmManager.StartSnapshotScheduler()
// Clean up stale container configs (LXC dir was deleted but config remains) // Clean up stale container configs (LXC dir was deleted but config remains)
config.CleanStaleContainers() config.CleanStaleContainers()
+123 -24
View File
@@ -12,6 +12,7 @@ interface CreateContainerModalProps {
const defaultForm: CreateContainerRequest = { const defaultForm: CreateContainerRequest = {
name: '', name: '',
virtualization: 'lxc',
template_id: '', template_id: '',
vcpu: 1, vcpu: 1,
cpu_percent: 100, cpu_percent: 100,
@@ -43,13 +44,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
getEnabledImages() getEnabledImages(form.virtualization)
.then((res) => { .then((res) => {
const data = res.data.data || [] const data = res.data.data || []
setTemplates(data) setTemplates(data)
if (data.length > 0) { setForm((prev) => ({ ...prev, template_id: data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '') }))
setForm((prev) => ({ ...prev, template_id: prev.template_id || data[0].id }))
}
}) })
.catch(console.error) .catch(console.error)
@@ -69,13 +68,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
getHostInfo() getHostInfo()
.then((res) => setHostInfo(res.data.data || null)) .then((res) => setHostInfo(res.data.data || null))
.catch(() => setHostInfo(null)) .catch(() => setHostInfo(null))
}, [isOpen]) }, [isOpen, form.virtualization])
const ipv6Available = !!ipv6Status?.available const ipv6Available = !!ipv6Status?.available
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || '' const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
const maxVCPU = hostInfo?.cpu.cores || 64 const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined 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 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 autoPorts = useMemo(() => {
const count = Math.max(2, form.port_mapping_count) const count = Math.max(2, form.port_mapping_count)
@@ -119,7 +119,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return 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 // Build batch of containers
const containers: CreateContainerRequest[] = [] const containers: CreateContainerRequest[] = []
@@ -181,10 +186,29 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</div> </div>
{batchCount > 1 && <p className="text-xs text-gray-400"> {batchCount} {form.name}-{batchStartIndex} {form.name}-{batchStartIndex + batchCount - 1}</p>} {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="系统模板"> <Field label="系统模板">
{templates.length === 0 ? ( {templates.length === 0 ? (
<div className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2"> <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> </div>
) : ( ) : (
<select <select
@@ -219,16 +243,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Field label="vCPU"> <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>
<Field label="内存 (MB)"> <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> </Field>
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<Field label="磁盘 (GB)"> <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>
<Field label="带宽 (Mbps)"> <Field label="带宽 (Mbps)">
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} /> <NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} />
@@ -342,44 +389,96 @@ function NumberInput({
min, min,
max, max,
step, step,
invalid,
onChange, onChange,
}: { }: {
value: number value: number
min?: number min?: number
max?: number max?: number
step?: number step?: number
invalid?: boolean
onChange: (value: number) => void 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 ( return (
<input <input
type="number" type="text"
value={value} inputMode={step && !Number.isInteger(step) ? 'decimal' : 'numeric'}
min={min} value={draft}
max={max} onFocus={() => setFocused(true)}
step={step} onBlur={() => {
setFocused(false)
setDraft(Number.isFinite(value) ? String(value) : '')
}}
onChange={(event) => { onChange={(event) => {
const raw = event.target.value const raw = event.target.value
const value = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10) setDraft(raw)
onChange(value) 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 { return {
...form, ...form,
vcpu: clampVCPU(form.vcpu, maxVCPU), vcpu: form.virtualization === 'kvm' ? Math.round(form.vcpu) : normalizeLXCvCPU(form.vcpu),
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512), ram_mb: Math.round(form.ram_mb),
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10), disk_gb: Math.round(form.disk_gb),
snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3), 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 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) { function clampInt(value: number, min: number, max?: number, fallback = min) {
+5 -2
View File
@@ -359,7 +359,7 @@ export default function ContainerDetail() {
const openReinstall = async () => { const openReinstall = async () => {
try { try {
const res = await getEnabledImages() const res = await getEnabledImages(container?.virtualization || 'lxc')
if (res.data.data) { if (res.data.data) {
setTemplates(res.data.data) setTemplates(res.data.data)
setSelectedTemplate(res.data.data[0]?.id || '') setSelectedTemplate(res.data.data[0]?.id || '')
@@ -728,6 +728,7 @@ export default function ContainerDetail() {
</div> </div>
<div className="flex items-center gap-2 flex-wrap mt-2"> <div className="flex items-center gap-2 flex-wrap mt-2">
<InfoTag color="blue"> {container.template}</InfoTag> <InfoTag color="blue"> {container.template}</InfoTag>
<InfoTag color="slate"> {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
<InfoTag color="emerald"> {container.ip || '-'}</InfoTag> <InfoTag color="emerald"> {container.ip || '-'}</InfoTag>
<InfoTag color="amber">NAT {mappingCount} </InfoTag> <InfoTag color="amber">NAT {mappingCount} </InfoTag>
<InfoTag color="violet">{publicHost}:{container.ssh_port}</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 = { const classes = {
blue: 'bg-blue-50 text-blue-700 border-blue-100', blue: 'bg-blue-50 text-blue-700 border-blue-100',
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100', emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100',
amber: 'bg-amber-50 text-amber-700 border-amber-100', amber: 'bg-amber-50 text-amber-700 border-amber-100',
violet: 'bg-violet-50 text-violet-700 border-violet-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> 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 { function getTemplateIcon(id: string): ReactNode {
const size = 'w-6 h-6' 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('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('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> 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>
+98 -56
View File
@@ -48,6 +48,7 @@ export default function Containers() {
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({}) const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({})
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
const [systemFilter, setSystemFilter] = useState('all') const [systemFilter, setSystemFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@@ -168,12 +169,13 @@ export default function Containers() {
const filteredContainers = useMemo(() => { const filteredContainers = useMemo(() => {
return filterContainers(displayContainers, { return filterContainers(displayContainers, {
search: searchText, search: searchText,
type: typeFilter,
system: systemFilter, system: systemFilter,
status: statusFilter, status: statusFilter,
taskStatusMap, taskStatusMap,
taskNameMap, taskNameMap,
}) })
}, [displayContainers, searchText, systemFilter, statusFilter, tasks]) }, [displayContainers, searchText, typeFilter, systemFilter, statusFilter, tasks])
const totalPages = Math.max(1, Math.ceil(filteredContainers.length / pageSize)) const totalPages = Math.max(1, Math.ceil(filteredContainers.length / pageSize))
const currentPage = Math.min(page, totalPages) const currentPage = Math.min(page, totalPages)
const pageStart = (currentPage - 1) * pageSize const pageStart = (currentPage - 1) * pageSize
@@ -185,7 +187,7 @@ export default function Containers() {
useEffect(() => { useEffect(() => {
setPage(1) setPage(1)
}, [searchText, systemFilter, statusFilter, pageSize]) }, [searchText, typeFilter, systemFilter, statusFilter, pageSize])
const toggleAll = () => { const toggleAll = () => {
if (allFilteredSelected) { if (allFilteredSelected) {
@@ -235,25 +237,42 @@ export default function Containers() {
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
{selected.size > 0 && ( <button
<div className="flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5"> onClick={handleRefreshList}
<span className="text-xs text-gray-500 mr-1">{selected.size} </span> disabled={refreshing}
<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"> 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 whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
<Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'} title="刷新列表"
>
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`} />
</button> </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"> <button
<Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'} onClick={() => setShowTasks(true)}
</button> 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 whitespace-nowrap"
<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 ? '执行中...' : '重启'} <ListTodo className="w-3.5 h-3.5" />
</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"> {activeTaskCount > 0 && (
<Trash2 className="w-3 h-3" />{batchLoading ? '执行中...' : '删除'} <span className="ml-0.5 rounded bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-700">
</button> {activeTaskCount}
</div> </span>
)} )}
</button>
{!isSubUser && (
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium whitespace-nowrap"
>
<Plus className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{displayContainers.length > 0 && ( {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]"> <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" /> <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 <input
@@ -263,6 +282,16 @@ export default function Containers() {
placeholder="搜索名称、ID、UUID、IP" placeholder="搜索名称、ID、UUID、IP"
/> />
</div> </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 <select
value={systemFilter} value={systemFilter}
onChange={(event) => setSystemFilter(event.target.value)} onChange={(event) => setSystemFilter(event.target.value)}
@@ -297,40 +326,27 @@ export default function Containers() {
<option value={20}>20 / </option> <option value={20}>20 / </option>
<option value={50}>50 / </option> <option value={50}>50 / </option>
</select> </select>
</> </div>
)}
<button {selected.size > 0 && (
onClick={handleRefreshList} <div className="flex flex-wrap items-center justify-end gap-1.5">
disabled={refreshing} <span className="text-xs text-gray-500">{selected.size} </span>
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 whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed" <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">
title="刷新列表" <Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'}
>
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`} />
</button> </button>
<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">
onClick={() => setShowTasks(true)} <Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'}
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 whitespace-nowrap"
>
<ListTodo className="w-3.5 h-3.5" />
{activeTaskCount > 0 && (
<span className="ml-0.5 rounded bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-700">
{activeTaskCount}
</span>
)}
</button> </button>
{!isSubUser && ( <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">
<button <RotateCcw className="w-3 h-3" />{batchLoading ? '执行中...' : '重启'}
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium whitespace-nowrap"
>
<Plus className="w-3.5 h-3.5" />
</button> </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> </div>
</div> )}
{displayContainers.length === 0 ? ( {displayContainers.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-lg p-12 text-center"> <div className="bg-white border border-gray-200 rounded-lg p-12 text-center">
@@ -343,7 +359,7 @@ export default function Containers() {
) : ( ) : (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden"> <div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[1200px]"> <table className="w-full min-w-[1260px]">
<thead> <thead>
<tr className="border-b border-gray-200 bg-gray-50"> <tr className="border-b border-gray-200 bg-gray-50">
<th className="w-10 px-3 py-3"> <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></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead icon><Cpu className="w-3.5 h-3.5" />CPU</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><MemoryStick className="w-3.5 h-3.5" />MEMORY</TableHead>
<TableHead icon><HardDrive className="w-3.5 h-3.5" />DISK</TableHead> <TableHead icon><HardDrive className="w-3.5 h-3.5" />DISK</TableHead>
@@ -421,6 +438,9 @@ export default function Containers() {
{getTemplateName(container.template)} {getTemplateName(container.template)}
</span> </span>
</td> </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"> <td className="px-2.5 py-2 align-top">
<ProgressCell pct={cpuPct} /> <ProgressCell pct={cpuPct} />
</td> </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( function buildDisplayContainers(
containers: Container[], containers: Container[],
queuedCreates: Record<string, CreateContainerRequest>, queuedCreates: Record<string, CreateContainerRequest>,
@@ -643,6 +672,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
id: 0, id: 0,
uuid: '', uuid: '',
name: cfg.name, name: cfg.name,
virtualization: cfg.virtualization || 'lxc',
template: cfg.template_id, template: cfg.template_id,
vcpu: cfg.vcpu, vcpu: cfg.vcpu,
ram_mb: cfg.ram_mb, ram_mb: cfg.ram_mb,
@@ -715,6 +745,7 @@ function hasActiveTasks(tasks: Task[]) {
type ContainerFilters = { type ContainerFilters = {
search: string search: string
type: string
system: string system: string
status: string status: string
taskStatusMap: Record<number, Task> taskStatusMap: Record<number, Task>
@@ -728,6 +759,9 @@ function filterContainers(containers: DisplayContainer[], filters: ContainerFilt
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) { if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
return false return false
} }
if (filters.type !== 'all' && (container.virtualization || 'lxc') !== filters.type) {
return false
}
if (filters.status !== 'all' && getContainerStatusFilterValue(container, task) !== filters.status) { if (filters.status !== 'all' && getContainerStatusFilterValue(container, task) !== filters.status) {
return false return false
} }
@@ -740,6 +774,7 @@ function filterContainers(containers: DisplayContainer[], filters: ContainerFilt
container.ip, container.ip,
container.ipv6, container.ipv6,
container.template, container.template,
container.virtualization || 'lxc',
getTemplateName(container.template), getTemplateName(container.template),
getSystemFilterLabel(getSystemFilterValue(container.template)), getSystemFilterLabel(getSystemFilterValue(container.template)),
String(container.ssh_port || ''), String(container.ssh_port || ''),
@@ -760,14 +795,15 @@ function buildSystemOptions(containers: DisplayContainer[]) {
} }
function getSystemFilterValue(template: string) { function getSystemFilterValue(template: string) {
if (template.startsWith('ubuntu')) return 'ubuntu' const normalized = template.startsWith('kvm-') ? template.slice(4) : template
if (template.startsWith('debian')) return 'debian' if (normalized.startsWith('ubuntu')) return 'ubuntu'
if (template.startsWith('alpine')) return 'alpine' if (normalized.startsWith('debian')) return 'debian'
if (template.startsWith('centos')) return 'centos' if (normalized.startsWith('alpine')) return 'alpine'
if (template.startsWith('archlinux')) return 'archlinux' if (normalized.startsWith('centos')) return 'centos'
if (template.startsWith('fedora')) return 'fedora' if (normalized.startsWith('archlinux')) return 'archlinux'
if (template.startsWith('rockylinux')) return 'rockylinux' if (normalized.startsWith('fedora')) return 'fedora'
return template || 'unknown' if (normalized.startsWith('rockylinux')) return 'rockylinux'
return normalized || 'unknown'
} }
function getSystemFilterLabel(system: string) { function getSystemFilterLabel(system: string) {
@@ -903,12 +939,18 @@ function getTemplateName(id: string) {
'archlinux-current': 'Arch Linux', 'archlinux-current': 'Arch Linux',
'fedora-44': 'Fedora 44', 'fedora-44': 'Fedora 44',
'rockylinux-10': 'Rocky 10', '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 return map[id] || id
} }
function getTemplateIcon(id: string): ReactNode { function getTemplateIcon(id: string): ReactNode {
const size = 'w-4 h-4' 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('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('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> 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>
+23 -1
View File
@@ -17,6 +17,7 @@ export default function ImageManagement() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null) const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
const fetchImages = useCallback(async () => { const fetchImages = useCallback(async () => {
try { try {
@@ -80,6 +81,7 @@ export default function ImageManagement() {
} }
const downloadedCount = images.filter((img) => img.downloaded).length const downloadedCount = images.filter((img) => img.downloaded).length
const visibleImages = images.filter((img) => typeFilter === 'all' || img.type === typeFilter)
if (loading) { if (loading) {
return ( return (
@@ -99,6 +101,16 @@ export default function ImageManagement() {
{downloadedCount}/{images.length} {downloadedCount}/{images.length}
</p> </p>
</div> </div>
<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 <button
onClick={fetchImages} 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" 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"
@@ -107,6 +119,7 @@ export default function ImageManagement() {
</button> </button>
</div> </div>
</div>
{error && ( {error && (
<div className="flex items-center gap-2 bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm text-red-700"> <div className="flex items-center gap-2 bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm text-red-700">
@@ -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 className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th> </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 className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th> </th>
@@ -141,7 +157,7 @@ export default function ImageManagement() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{images.map((img) => { {visibleImages.map((img) => {
const isBusy = actionLoading === img.id const isBusy = actionLoading === img.id
return ( return (
<tr key={img.id} className="hover:bg-gray-50 transition-colors"> <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"> <td className="px-4 py-3 text-xs text-gray-600 font-mono">
{img.distro} {img.release} {img.distro} {img.release}
</td> </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"> <td className="px-4 py-3 text-xs text-gray-500 font-mono">
{img.arch} {img.arch}
</td> </td>
@@ -264,6 +285,7 @@ function StatusBadge({ img }: { img: ImageInfo }) {
function getTemplateIcon(id: string): ReactNode { function getTemplateIcon(id: string): ReactNode {
const size = 'w-5 h-5' 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('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('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> 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>
+7 -3
View File
@@ -48,6 +48,7 @@ export interface Container {
id: number id: number
uuid: string uuid: string
name: string name: string
virtualization?: string
template: string template: string
vcpu: number vcpu: number
ram_mb: number ram_mb: number
@@ -85,6 +86,7 @@ export interface Container {
export interface Template { export interface Template {
id: string id: string
name: string name: string
type?: string
distro: string distro: string
release: string release: string
arch: string arch: string
@@ -94,6 +96,7 @@ export interface Template {
export interface CreateContainerRequest { export interface CreateContainerRequest {
name: string name: string
virtualization: string
template_id: string template_id: string
vcpu: number vcpu: number
cpu_percent: number cpu_percent: number
@@ -338,6 +341,7 @@ export const getTemplates = () =>
export interface ImageInfo { export interface ImageInfo {
id: string id: string
name: string name: string
type: string
distro: string distro: string
release: string release: string
arch: string arch: string
@@ -352,7 +356,7 @@ export const getImages = () =>
api.get<APIResponse<ImageInfo[]>>('/images') api.get<APIResponse<ImageInfo[]>>('/images')
export const downloadImage = (templateId: string) => 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) => export const deleteImage = (templateId: string) =>
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } }) 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) => export const toggleImage = (templateId: string, enabled: boolean) =>
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled }) api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
export const getEnabledImages = () => export const getEnabledImages = (virtualization = 'lxc') =>
api.get<APIResponse<Template[]>>('/images/enabled') api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } })
// Dashboard // Dashboard
export const getDashboard = () => export const getDashboard = () =>