Compare commits

...

8 Commits

Author SHA1 Message Date
MengMengCode be17f669f7 release: v1.0.11 2026-06-07 10:48:40 +08:00
MengMengCode 65fc787070 优化了一些功能 2026-06-07 10:48:25 +08:00
MengMengCode 245c57449c 初步支持KVM 2026-06-07 09:24:21 +08:00
MengMengCode 6dd7079e23 初步支持KVM 2026-06-07 09:24:09 +08:00
MengMengCode 422e48b524 release: v1.0.10 2026-06-06 15:53:14 +08:00
MengMengCode 3488b6db56 优化了一些功能 2026-06-06 15:52:27 +08:00
MengMengCode c7ba19fa34 release: v1.0.9 2026-06-06 15:10:54 +08:00
MengMengCode ffedf801e7 添加了子用户列表功能 2026-06-06 15:10:24 +08:00
35 changed files with 4347 additions and 1099 deletions
Submodule .claude/worktrees/agent-ae3871aebda20eb86 added at 422e48b524
+12 -9
View File
@@ -19,14 +19,13 @@ CLICD 是一个面向 LXC 的轻量容器管理面板,提供 Web 控制台、C
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
3. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器
4. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段
5. 支持超售容量估算。宿主机控制页提供 KSM 合并、Swap 倾向和 cgroup v2 `memory.reclaim` 一次性回收能力;不会展示 LXC 下无实际通用效果的内存气球回收开关
6. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制
7. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式
8. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用
9. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额
10. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段
4. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额
5. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用
6. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志
7. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式
## 技术栈
@@ -61,4 +60,8 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
</picture>
</a>
</a>
## 鸣谢
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
+21
View File
@@ -67,6 +67,27 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
return nil, false
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, false
}
// For sub-user tokens, check token_version against stored version (password rotation invalidation)
if subUser, _ := claims["sub_user"].(string); subUser != "" {
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
tokenVersion := int(tokenVersionFloat)
for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].Username == subUser {
stored := config.AppConfig.SubUsers[i].TokenVersion
// If stored version > 0, require token_version to match exactly.
// This also rejects legacy tokens that lack token_version entirely.
if stored > 0 && (!hasVersion || tokenVersion != stored) {
return nil, false
}
break
}
}
}
return claims, ok
}
+34 -20
View File
@@ -31,16 +31,35 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/containers/")
parts := strings.SplitN(path, "/", 2)
c := containerByIdentifier(parts[0])
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
id := 0
if c != nil {
id = c.ID
}
id := c.ID
action := ""
if len(parts) > 1 {
action = parts[1]
}
// Snapshot delete/restore operations: allow even if the container was deleted
isSnapshotDelete := strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete
isSnapshotRestore := strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost
isSnapshotAction := isSnapshotDelete || isSnapshotRestore
if !isSnapshotAction && c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
if isSnapshotAction && id == 0 {
// For orphaned snapshots, resolve containerID from the snapshot itself
snapshotID := strings.TrimPrefix(action, "snapshots/")
snapshotID = strings.TrimSuffix(snapshotID, "/restore")
snapshot := config.FindSnapshot(snapshotID)
if snapshot == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
return
}
id = snapshot.ContainerID
}
switch {
case action == "start" && r.Method == http.MethodPost:
HandleSingleTaskAction(w, r, id, "start")
@@ -86,10 +105,7 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
}
func listContainers(w http.ResponseWriter, r *http.Request) {
containers, err := lxcManager.ListContainers()
if err != nil {
containers = config.AppConfig.Containers
}
containers, _ := listByRuntime()
containers = filterContainersForRequest(r, containers)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers})
}
@@ -104,11 +120,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
return
}
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
if cfg.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
return
}
if !isTemplateEnabledAndDownloaded(cfg.TemplateID) {
if !isImageEnabledAndDownloaded(cfg.TemplateID, cfg.Virtualization) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
@@ -131,7 +148,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
if err := validateRuntimeResourceRequest(cfg.Virtualization, cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
@@ -147,7 +164,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
}
}
if err := lxcManager.CreateContainer(cfg); err != nil {
if err := createByRuntime(cfg); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
@@ -164,7 +181,7 @@ func getContainer(w http.ResponseWriter, r *http.Request, id int) {
}
func getUsage(w http.ResponseWriter, r *http.Request, id int) {
usage, err := lxcManager.GetResourceUsage(id)
usage, err := usageByRuntime(id)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
@@ -173,7 +190,7 @@ func getUsage(w http.ResponseWriter, r *http.Request, id int) {
}
func getTraffic(w http.ResponseWriter, r *http.Request, id int) {
info := lxcManager.GetTrafficInfo(id)
info := trafficByRuntime(id)
if info == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
@@ -262,7 +279,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
if req.RAMMB > 0 {
nextRAMMB = req.RAMMB
}
if err := validateContainerResourceRequest(nextVCPU, nextRAMMB, c.DiskGB); err != nil {
if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
@@ -275,7 +292,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
// Re-apply resource limits to running container
if c.Status == "running" {
if err := lxcManager.ApplyContainerLimits(c); err != nil {
if err := applyLimitsByRuntime(c); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
@@ -335,10 +352,7 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
containers, err := lxcManager.ListContainers()
if err != nil {
containers = config.AppConfig.Containers
}
containers, _ := listByRuntime()
running := 0
stopped := 0
for _, c := range containers {
@@ -372,7 +386,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
return
}
newPassword, err := lxcManager.ResetSSHPassword(id)
newPassword, err := resetPasswordByRuntime(id)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
+104 -8
View File
@@ -10,6 +10,7 @@ import (
"sync"
"clicd/internal/config"
"clicd/internal/kvm"
"clicd/internal/lxc"
)
@@ -17,6 +18,7 @@ import (
type ImageInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Distro string `json:"distro"`
Release string `json:"release"`
Arch string `json:"arch"`
@@ -78,6 +80,9 @@ func getEnabledImageSet() map[string]bool {
for _, t := range lxc.GetTemplates() {
set[t.ID] = true
}
for _, t := range kvm.GetImages() {
set[t.ID] = true
}
} else {
for _, id := range config.AppConfig.EnabledImages {
set[id] = true
@@ -93,16 +98,34 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
return
}
templates := lxc.GetTemplates()
enabledSet := getEnabledImageSet()
images := make([]ImageInfo, 0, len(templates))
templates := lxc.GetTemplates()
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
for _, t := range templates {
_, downloading := imageDownloads[t.ID]
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
images = append(images, ImageInfo{
ID: t.ID,
Name: t.Name,
Type: config.VirtualizationLXC,
Distro: t.Distro,
Release: t.Release,
Arch: t.Arch,
Description: t.Description,
Downloaded: downloaded,
Enabled: enabledSet[t.ID],
Downloading: downloading,
SizeBytes: size,
})
}
for _, t := range kvm.GetImages() {
_, downloading := imageDownloads[t.ID]
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
images = append(images, ImageInfo{
ID: t.ID,
Name: t.Name,
Type: config.VirtualizationKVM,
Distro: t.Distro,
Release: t.Release,
Arch: t.Arch,
@@ -134,7 +157,35 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
image := kvm.FindImage(req.TemplateID)
if image == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return
}
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
ensureImageEnabled(image.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return
}
imageDownloadsMu.Lock()
if imageDownloads[req.TemplateID] {
imageDownloadsMu.Unlock()
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return
}
imageDownloads[req.TemplateID] = true
imageDownloadsMu.Unlock()
defer func() {
imageDownloadsMu.Lock()
delete(imageDownloads, req.TemplateID)
imageDownloadsMu.Unlock()
}()
ensureImageEnabled(image.ID)
if err := kvm.DownloadImage(*image); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Download failed: " + err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
return
}
@@ -206,6 +257,15 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil {
if image := kvm.FindImage(req.TemplateID); image != nil {
if err := kvm.DeleteImage(image.ID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + err.Error()})
return
}
removeImageEnabled(image.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"})
return
}
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return
}
@@ -259,13 +319,27 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
return
}
templates := lxc.GetTemplates()
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
enabledSet := getEnabledImageSet()
result := make([]lxc.Template, 0)
for _, t := range templates {
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
result = append(result, t)
result := make([]map[string]string, 0)
if runtime == config.VirtualizationKVM {
for _, t := range kvm.GetImages() {
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"description": t.Description, "type": config.VirtualizationKVM,
})
}
}
} else {
for _, t := range lxc.GetTemplates() {
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
})
}
}
}
@@ -273,6 +347,20 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
}
func isTemplateEnabledAndDownloaded(templateID string) bool {
return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID))
}
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
runtime = runtimeFromRequest(runtime)
if runtime == config.VirtualizationKVM {
image := kvm.FindImage(templateID)
if image == nil {
return false
}
enabledSet := getEnabledImageSet()
downloaded, _ := kvm.ImageDownloadedInfo(image.ID)
return enabledSet[image.ID] && downloaded
}
tmpl := lxc.FindTemplate(templateID)
if tmpl == nil {
return false
@@ -288,6 +376,9 @@ func ensureImageEnabled(id string) {
for _, t := range lxc.GetTemplates() {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
}
for _, t := range kvm.GetImages() {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
}
config.SaveConfig()
return // Already contains all IDs including this one
}
@@ -313,6 +404,11 @@ func removeImageEnabled(id string) {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
}
}
for _, t := range kvm.GetImages() {
if t.ID != id {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
}
}
config.SaveConfig()
return
}
+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) {
c, err := lxcManager.AssignIPv6(id)
c, err := assignIPv6ByRuntime(id)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
-227
View File
@@ -1,227 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"clicd/internal/config"
)
// HandleOversell handles GET/POST for oversell config
func HandleOversell(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
getOversell(w, r)
case http.MethodPost:
updateOversell(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func getOversell(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: config.AppConfig.Oversell})
}
func updateOversell(w http.ResponseWriter, r *http.Request) {
var cfg config.OversellConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if cfg.SubUserSnapshotLimit <= 0 {
cfg.SubUserSnapshotLimit = 3
}
// Apply KSM
if cfg.KSMEnabled {
exec.Command("sh", "-c", "echo 1 > /sys/kernel/mm/ksm/run 2>/dev/null").Run()
exec.Command("sh", "-c", "echo 1000 > /sys/kernel/mm/ksm/sleep_millisecs 2>/dev/null").Run()
} else {
exec.Command("sh", "-c", "echo 0 > /sys/kernel/mm/ksm/run 2>/dev/null").Run()
}
// Apply swappiness
if cfg.Swappiness >= 0 && cfg.Swappiness <= 100 {
exec.Command("sh", "-c", fmt.Sprintf("echo %d > /proc/sys/vm/swappiness", cfg.Swappiness)).Run()
}
// Oversell multipliers are capacity-planning values. They must not increase
// an individual container's CPU or RAM limits.
reapplyContainerLimits()
config.AppConfig.Oversell = cfg
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save config"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Oversell config updated", Data: cfg})
}
// reapplyContainerLimits restores cgroup limits for all running containers from
// their assigned container resources.
func reapplyContainerLimits() {
for _, c := range config.AppConfig.Containers {
if c.Status != "running" {
continue
}
if err := lxcManager.ApplyContainerLimits(&c); err != nil {
fmt.Printf("Warning: failed to reapply resource limits for %s: %v\n", c.LxcName(), err)
}
}
}
// HandleOversellStatus returns current oversell resource usage
func HandleOversellStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
status := map[string]interface{}{
"ksm_active": isKSMEnabled(),
"ksm_pages": getKSMPages(),
"ksm_supported": isKSMSupported(),
"swappiness": getSwappiness(),
"reclaim_supported": isMemoryReclaimSupported(),
"allocated_cpu": getAllocatedCPU(),
"allocated_ram_mb": getAllocatedRAM(),
"allocated_disk_gb": getAllocatedDisk(),
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
}
// HandleOversellReclaim triggers one cgroup v2 memory.reclaim pass for running containers.
func HandleOversellReclaim(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
result := reclaimContainerMemory()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Memory reclaim triggered", Data: result})
}
func reclaimContainerMemory() map[string]interface{} {
attempted := 0
reclaimed := 0
unsupported := 0
errors := make([]string, 0)
for _, c := range config.AppConfig.Containers {
if c.Status != "running" {
continue
}
attempted++
reclaimPath := findMemoryReclaimPath(c.LxcName())
if reclaimPath == "" {
unsupported++
continue
}
if err := os.WriteFile(reclaimPath, []byte("64M"), 0644); err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", c.Name, err))
continue
}
reclaimed++
}
return map[string]interface{}{
"attempted": attempted,
"reclaimed": reclaimed,
"unsupported": unsupported,
"errors": errors,
}
}
func isKSMEnabled() bool {
data, err := os.ReadFile("/sys/kernel/mm/ksm/run")
if err != nil {
return false
}
return strings.TrimSpace(string(data)) == "1"
}
func isKSMSupported() bool {
if _, err := os.Stat("/sys/kernel/mm/ksm/run"); err != nil {
return false
}
return true
}
func getKSMPages() int64 {
data, err := os.ReadFile("/sys/kernel/mm/ksm/pages_shared")
if err != nil {
return 0
}
val, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return val
}
func getSwappiness() int {
data, err := os.ReadFile("/proc/sys/vm/swappiness")
if err != nil {
return 60
}
val, _ := strconv.Atoi(strings.TrimSpace(string(data)))
return val
}
func isMemoryReclaimSupported() bool {
if _, err := os.Stat("/sys/fs/cgroup/memory.reclaim"); err == nil {
return true
}
for _, c := range config.AppConfig.Containers {
if c.Status != "running" {
continue
}
if findMemoryReclaimPath(c.LxcName()) != "" {
return true
}
}
return false
}
func findMemoryReclaimPath(lxcName string) string {
candidates := []string{
fmt.Sprintf("/sys/fs/cgroup/lxc/%s/memory.reclaim", lxcName),
fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/memory.reclaim", lxcName),
fmt.Sprintf("/sys/fs/cgroup/system.slice/lxc@%s.service/memory.reclaim", lxcName),
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func getAllocatedCPU() float64 {
total := 0.0
for _, c := range config.AppConfig.Containers {
total += c.VCPU
}
return total
}
func getAllocatedRAM() int64 {
total := int64(0)
for _, c := range config.AppConfig.Containers {
total += int64(c.RAMMB)
}
return total
}
func getAllocatedDisk() int64 {
total := int64(0)
for _, c := range config.AppConfig.Containers {
total += int64(c.DiskGB)
}
return total
}
+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
}
}
snapshot, err := lxcManager.CreateSnapshot(containerID, user, false, 0)
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
@@ -138,7 +138,7 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
req.Time = "03:00"
}
user := requestUser(r)
c, err := lxcManager.SetSnapshotSchedule(containerID, req.Enabled, req.IntervalHours, req.Time, user)
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
@@ -162,7 +162,7 @@ func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
return
}
user := requestUser(r)
if err := lxcManager.DeleteSnapshot(snapshotID); err != nil {
if err := deleteSnapshotByRuntime(snapshotID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
@@ -177,7 +177,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
return
}
user := requestUser(r)
if err := lxcManager.RestoreSnapshot(snapshotID); err != nil {
if err := restoreSnapshotByRuntime(snapshotID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
+62 -21
View File
@@ -101,16 +101,30 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
return
}
if c.IP == "" {
if ip, err := lxcManager.GetContainerIP(c.LxcName()); err == nil {
var ip string
var err error
if c.IsKVM() {
ip, err = kvmManager.GetContainerIP(c.VirshName())
} else {
ip, err = lxcManager.GetContainerIP(c.LxcName())
}
if err == nil {
c.IP = ip
config.SaveConfig()
}
}
if c.IP == "" {
if c.IP == "" && !c.IsKVM() {
if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" {
c.IP = ip
}
}
if c.IP == "" && c.IsKVM() {
if err := kvmManager.EnsureSSH(c.ID); err == nil {
if refreshed := config.FindContainer(c.ID); refreshed != nil {
c = refreshed
}
}
}
if c.IP == "" {
http.Error(w, "container ip is not available", http.StatusBadRequest)
return
@@ -127,6 +141,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
defer ws.Close()
if c.SSHPassword == "" {
if c.IsKVM() {
writeWebSocketText(ws, nil, "\r\nKVM SSH password is not available. Reinstall or reset after SSH is ready.\r\n")
return
}
writeWebSocketText(ws, nil, "\r\nPreparing SSH service. This can take up to 90 seconds on first boot...\r\n")
if err := lxcManager.EnsureSSH(c.ID); err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err))
@@ -154,25 +172,48 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr))
client, err := ssh.Dial("tcp", addr, sshConfig)
if err != nil {
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n")
if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
return
}
if refreshed := config.FindContainer(c.ID); refreshed != nil {
c = refreshed
}
if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" {
c.IP = ip
config.SaveConfig()
addr = net.JoinHostPort(c.IP, "22")
}
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
sshConfig.Timeout = 10 * time.Second
client, err = ssh.Dial("tcp", addr, sshConfig)
if err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
return
if c.IsKVM() {
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing KVM guest service. This can take a few minutes on first boot...\r\n")
if setupErr := kvmManager.EnsureSSH(c.ID); setupErr != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nKVM SSH auto setup failed: %v\r\n", setupErr))
return
}
if refreshed := config.FindContainer(c.ID); refreshed != nil {
c = refreshed
}
if ip, ipErr := kvmManager.GetContainerIP(c.VirshName()); ipErr == nil && ip != "" {
c.IP = ip
config.SaveConfig()
addr = net.JoinHostPort(c.IP, "22")
}
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
sshConfig.Timeout = 10 * time.Second
client, err = ssh.Dial("tcp", addr, sshConfig)
if err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
return
}
} else {
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n")
if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
return
}
if refreshed := config.FindContainer(c.ID); refreshed != nil {
c = refreshed
}
if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" {
c.IP = ip
config.SaveConfig()
addr = net.JoinHostPort(c.IP, "22")
}
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
sshConfig.Timeout = 10 * time.Second
client, err = ssh.Dial("tcp", addr, sshConfig)
if err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
return
}
}
}
defer client.Close()
+192 -10
View File
@@ -66,7 +66,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
}
containerName := c.Name
// Check if sub-user already exists for this container
// Check if sub-user already exists and return the same management password.
for i := range config.AppConfig.SubUsers {
su := &config.AppConfig.SubUsers[i]
for _, uuid := range su.ContainerUUIDs {
@@ -74,18 +74,27 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
if su.AccessCode == "" {
su.AccessCode = generateRandomStr(8)
}
password := generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
password := su.Password
message := "Sub-user link returned"
if password == "" {
password = generateRandomStr(16)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
return
}
su.PassHash = string(hash)
su.Password = password
su.Token = ""
su.TokenVersion++
message = "Sub-user password generated"
}
su.Password = ""
su.Token = ""
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Message: "Sub-user password rotated",
Message: message,
Data: newSubUserResponse(*su, password),
})
return
@@ -104,6 +113,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
subUser := config.SubUser{
ID: "sub-" + generateRandomStr(8),
Username: username,
Password: password,
PassHash: string(hash),
ContainerNames: []string{containerName},
ContainerUUIDs: []string{c.UUID},
@@ -134,17 +144,24 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
return
}
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
clientUA := r.Header.Get("User-Agent")
// Find sub-user
for _, su := range config.AppConfig.SubUsers {
if su.Username == req.Username {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
// Generate fresh token
containerUUIDs := activeSubUserContainerUUIDs(&su)
if len(containerUUIDs) == 0 {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
return
}
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion)
config.AddLoginLog(su.Username, clientIP, clientUA, true)
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
@@ -155,6 +172,8 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
},
})
return
} else {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
}
}
}
@@ -179,19 +198,28 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
}
// Find sub-user by access code
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
clientUA := r.Header.Get("User-Agent")
for _, su := range config.AppConfig.SubUsers {
if su.AccessCode == req.Code {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"})
return
}
containerUUIDs := activeSubUserContainerUUIDs(&su)
if len(containerUUIDs) == 0 {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
return
}
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion)
config.AddLoginLog(su.Username, clientIP, clientUA, true)
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
@@ -208,10 +236,11 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"})
}
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time) string {
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time, tokenVersion int) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub_user": username,
"container_uuids": containerUUIDs,
"token_version": tokenVersion,
"exp": expiresAt.Unix(),
"iat": time.Now().Unix(),
})
@@ -461,3 +490,156 @@ func splitBy(s, sep string) []string {
result = append(result, current)
return result
}
// SubUserListItem is the enriched sub-user info returned by the list API
type SubUserListItem struct {
ID string `json:"id"`
Username string `json:"username"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids"`
ContainerName string `json:"container_name"`
ContainerUUID string `json:"container_uuid"`
AccessCode string `json:"access_code"`
Password string `json:"password,omitempty"`
CreatedAt string `json:"created_at"`
LastLogin string `json:"last_login"`
LastLoginIP string `json:"last_login_ip"`
LastLoginUA string `json:"last_login_ua"`
}
// HandleSubUserList returns the list of all sub-users with container info
func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
for _, su := range config.AppConfig.SubUsers {
item := SubUserListItem{
ID: su.ID,
Username: su.Username,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AccessCode: su.AccessCode,
Password: su.Password,
CreatedAt: su.CreatedAt,
}
// Resolve container name from first active UUID
for _, uuid := range su.ContainerUUIDs {
if c := config.FindContainerByUUID(uuid); c != nil {
item.ContainerName = c.Name
item.ContainerUUID = c.UUID
break
}
}
if item.ContainerName == "" && len(su.ContainerNames) > 0 {
item.ContainerName = su.ContainerNames[0]
}
// Find last login time
for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- {
log := config.AppConfig.LoginLogs[i]
if log.Username == su.Username {
item.LastLogin = log.Time
item.LastLoginIP = log.IP
item.LastLoginUA = log.UserAgent
break
}
}
// Skip orphaned sub-users with no active containers
if item.ContainerName == "" && item.ContainerUUID == "" {
continue
}
result = append(result, item)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
}
// HandleSubUserAction handles actions on a specific sub-user
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/")
parts := strings.SplitN(path, "/", 2)
subUserID := parts[0]
action := ""
if len(parts) > 1 {
action = parts[1]
}
// Find sub-user
var target *config.SubUser
for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].ID == subUserID {
target = &config.AppConfig.SubUsers[i]
break
}
}
if target == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Sub-user not found"})
return
}
switch {
case action == "rotate-password" && r.Method == http.MethodPost:
password := generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
target.PassHash = string(hash)
target.Password = password
target.Token = ""
target.TokenVersion++ // invalidate all existing tokens
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
"password": password,
"access_code": target.AccessCode,
"username": target.Username,
}})
return
}
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
case action == "audit-logs" && r.Method == http.MethodGet:
// Filter audit logs for this sub-user
logs := filterSubUserAuditLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
case action == "login-logs" && r.Method == http.MethodGet:
// Filter login logs for this sub-user
logs := filterSubUserLoginLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
}
}
func filterSubUserAuditLogs(username string) []config.AuditLog {
result := make([]config.AuditLog, 0)
for i := len(config.AppConfig.AuditLogs) - 1; i >= 0; i-- {
log := config.AppConfig.AuditLogs[i]
if log.User == username || strings.HasPrefix(log.User, "user:") && strings.Contains(log.User, username) {
result = append(result, log)
}
}
if result == nil {
result = []config.AuditLog{}
}
return result
}
func filterSubUserLoginLogs(username string) []config.SavedLoginLog {
result := make([]config.SavedLoginLog, 0)
for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- {
log := config.AppConfig.LoginLogs[i]
if log.Username == username {
result = append(result, log)
}
}
if result == nil {
result = []config.SavedLoginLog{}
}
return result
}
+34 -15
View File
@@ -35,6 +35,8 @@ type Task struct {
Config lxc.ContainerConfig `json:"config,omitempty"`
Name string `json:"name,omitempty"`
User string `json:"user,omitempty"` // who created this task
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
type TaskQueue struct {
@@ -100,6 +102,10 @@ func (q *TaskQueue) EnqueueBatch(taskType TaskType, ids []int, templateID string
}
func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string {
return q.EnqueueBatchWithAudit(taskType, ids, templateID, user, "", "")
}
func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, templateID string, user string, ip string, userAgent string) []string {
q.mu.Lock()
defer q.mu.Unlock()
var result []string
@@ -109,7 +115,7 @@ func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateI
if c != nil {
name = c.Name
}
result = append(result, q.enqueueSingleWithUser(id, name, taskType, templateID, user))
result = append(result, q.enqueueSingleWithAudit(id, name, taskType, templateID, user, ip, userAgent))
}
q.persistTasks()
return result
@@ -168,6 +174,10 @@ func (q *TaskQueue) enqueueSingle(containerID int, containerName string, taskTyp
}
func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string {
return q.enqueueSingleWithAudit(containerID, containerName, taskType, templateID, user, "", "")
}
func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string, taskType TaskType, templateID string, user string, ip string, userAgent string) string {
id := q.nextID
q.nextID++
task := &Task{
@@ -179,6 +189,8 @@ func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID,
User: user,
IP: ip,
UserAgent: userAgent,
}
q.enqueueTask(task)
return task.ID
@@ -214,7 +226,7 @@ func (q *TaskQueue) createWorker() {
c := config.FindContainerByName(task.Config.Name)
if c == nil {
// 1) Download image + apply limits (lxc-create)
err := lxcManager.CreateContainer(task.Config)
err := createByRuntime(task.Config)
if err != nil {
task.Status = "failed"
task.Error = err.Error()
@@ -244,10 +256,10 @@ func (q *TaskQueue) createWorker() {
// 3) Start + initialize SSH/network in the same worker.
// If init fails, destroy the container so no dead entry remains.
startErr := lxcManager.StartContainer(c.ID)
startErr := startByRuntime(c.ID)
if startErr != nil {
if createdByTask {
lxcManager.DestroyContainer(c.ID)
_ = destroyByRuntime(c.ID)
}
task.Status = "failed"
task.Error = startErr.Error()
@@ -292,13 +304,13 @@ func (q *TaskQueue) opWorker() {
if err == nil {
switch task.Type {
case TaskStart:
err = lxcManager.StartContainer(task.ContainerID)
err = startByRuntime(task.ContainerID)
case TaskStop:
err = lxcManager.StopContainer(task.ContainerID)
err = stopByRuntime(task.ContainerID)
case TaskRestart:
err = lxcManager.RestartContainer(task.ContainerID)
err = restartByRuntime(task.ContainerID)
case TaskDelete:
err = lxcManager.DestroyContainer(task.ContainerID)
err = destroyByRuntime(task.ContainerID)
if err == nil {
time.Sleep(1 * time.Second)
if config.FindContainer(task.ContainerID) != nil {
@@ -306,7 +318,7 @@ func (q *TaskQueue) opWorker() {
}
}
case TaskReinstall:
err = lxcManager.ReinstallContainer(task.ContainerID, task.TemplateID)
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
}
@@ -318,10 +330,10 @@ func (q *TaskQueue) opWorker() {
if err != nil {
task.Status = "failed"
task.Error = err.Error()
config.AddAuditLog(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser)
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
} else {
task.Status = "done"
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", auditUser)
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
switch task.Type {
case TaskStart:
config.UpdateContainerStatus(task.ContainerID, "running")
@@ -418,6 +430,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
user = "user:" + subUser
}
}
ip := clientIP(r)
userAgent := r.Header.Get("User-Agent")
var taskType TaskType
var templateID string
@@ -442,7 +456,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
templateID = c.Template
}
}
if !isTemplateEnabledAndDownloaded(templateID) {
runtime := runtimeFromTemplateID(templateID)
if c := config.FindContainer(id); c != nil {
runtime = c.Runtime()
}
if !isImageEnabledAndDownloaded(templateID, runtime) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
@@ -452,7 +470,7 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
return
}
ids := globalQueue.EnqueueBatchWithUser(taskType, []int{id}, templateID, user)
ids := globalQueue.EnqueueBatchWithAudit(taskType, []int{id}, templateID, user, ip, userAgent)
jsonResponse(w, http.StatusAccepted, APIResponse{
Success: true,
Message: "Task queued",
@@ -502,13 +520,14 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].VCPU <= 0 {
req.Containers[i].VCPU = 1
}
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
if req.Containers[i].RAMMB < 128 {
req.Containers[i].RAMMB = 512
}
if req.Containers[i].DiskGB < 1 {
req.Containers[i].DiskGB = 5
}
if !isTemplateEnabledAndDownloaded(req.Containers[i].TemplateID) {
if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
return
}
@@ -518,7 +537,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].SnapshotLimit <= 0 {
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
}
if err := validateContainerResourceRequest(req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
if err := validateRuntimeResourceRequest(req.Containers[i].Virtualization, req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
+106 -37
View File
@@ -47,21 +47,15 @@ type SavedLoginLog struct {
// AuditLog represents an operation log entry
type AuditLog struct {
Time string `json:"time"`
Action string `json:"action"`
Target string `json:"target"`
Detail string `json:"detail"`
User string `json:"user"`
}
// OversellConfig controls host-level overselling behavior
type OversellConfig struct {
CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell
RAMOvercommit int `json:"ram_overcommit"` // multiplier
DiskOvercommit int `json:"disk_overcommit"` // multiplier
KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging
Swappiness int `json:"swappiness"` // 0-100, lower = less swap
SubUserSnapshotLimit int `json:"sub_user_snapshot_limit"` // legacy default for migrating old containers
Time string `json:"time"`
Action string `json:"action"`
Target string `json:"target"`
Detail string `json:"detail"`
User string `json:"user"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
Success *bool `json:"success,omitempty"`
Error string `json:"error,omitempty"`
}
// Container represents an LXC container configuration
@@ -69,7 +63,11 @@ type Container struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
LXCName string `json:"lxc_name,omitempty"`
KVMName string `json:"kvm_name,omitempty"`
DiskImage string `json:"disk_image,omitempty"`
MACAddress string `json:"mac_address,omitempty"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
@@ -105,6 +103,28 @@ type Container struct {
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
}
const (
VirtualizationLXC = "lxc"
VirtualizationKVM = "kvm"
)
func NormalizeVirtualization(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case VirtualizationKVM:
return VirtualizationKVM
default:
return VirtualizationLXC
}
}
func (c *Container) Runtime() string {
return NormalizeVirtualization(c.Virtualization)
}
func (c *Container) IsKVM() bool {
return c.Runtime() == VirtualizationKVM
}
// LxcName returns the internal LXC container name (ct-{id})
func (c *Container) LxcName() string {
if c.LXCName != "" {
@@ -113,6 +133,14 @@ func (c *Container) LxcName() string {
return fmt.Sprintf("ct-%d", c.ID)
}
// VirshName returns the internal libvirt domain name for KVM instances.
func (c *Container) VirshName() string {
if c.KVMName != "" {
return c.KVMName
}
return fmt.Sprintf("vm-%d", c.ID)
}
// SubUser represents a sub-user with access to specific containers
type ApiKeyConfig struct {
ID string `json:"id"`
@@ -139,13 +167,14 @@ func DeleteApiKey(id string) {
type SubUser struct {
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
Password string `json:"password,omitempty"`
PassHash string `json:"pass_hash"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
Token string `json:"-"`
AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"`
TokenVersion int `json:"token_version"`
}
type Snapshot struct {
@@ -172,7 +201,6 @@ type ClicdConfig struct {
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"`
Oversell OversellConfig `json:"oversell"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
AuditLogs []AuditLog `json:"audit_logs"`
@@ -273,14 +301,6 @@ func InitConfig() (*ClicdConfig, error) {
AuditLogs: []AuditLog{},
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Oversell: OversellConfig{
CPUOvercommit: 4,
RAMOvercommit: 1,
DiskOvercommit: 2,
KSMEnabled: true,
Swappiness: 10,
SubUserSnapshotLimit: 3,
},
Snapshots: []Snapshot{},
}
@@ -334,10 +354,10 @@ func InitConfig() (*ClicdConfig, error) {
if AppConfig.Snapshots == nil {
AppConfig.Snapshots = make([]Snapshot, 0)
}
if AppConfig.Oversell.SubUserSnapshotLimit <= 0 {
AppConfig.Oversell.SubUserSnapshotLimit = 3
}
changed := ensureContainerUUIDs()
if ensureContainerVirtualization() {
changed = true
}
if ensureContainerPortMappingLimits() {
changed = true
}
@@ -362,6 +382,18 @@ func InitConfig() (*ClicdConfig, error) {
return AppConfig, nil
}
func ensureContainerVirtualization() bool {
changed := false
for i := range AppConfig.Containers {
next := NormalizeVirtualization(AppConfig.Containers[i].Virtualization)
if AppConfig.Containers[i].Virtualization != next {
AppConfig.Containers[i].Virtualization = next
changed = true
}
}
return changed
}
func ensureContainerSnapshotScheduleDefaults() bool {
changed := false
for i := range AppConfig.Containers {
@@ -414,13 +446,9 @@ func ensureContainerPortMappingLimits() bool {
func ensureContainerSnapshotLimits() bool {
changed := false
legacyLimit := AppConfig.Oversell.SubUserSnapshotLimit
if legacyLimit <= 0 {
legacyLimit = DefaultSnapshotLimit
}
for i := range AppConfig.Containers {
if AppConfig.Containers[i].SnapshotLimit <= 0 {
AppConfig.Containers[i].SnapshotLimit = legacyLimit
AppConfig.Containers[i].SnapshotLimit = DefaultSnapshotLimit
changed = true
}
}
@@ -437,10 +465,6 @@ func migrateSubUsers() bool {
changed = true
}
}
if su.Password != "" {
su.Password = ""
changed = true
}
if su.Token != "" {
su.Token = ""
changed = true
@@ -518,6 +542,7 @@ func AddContainer(c Container) {
if c.UUID == "" {
c.UUID = NewContainerUUID()
}
c.Virtualization = NormalizeVirtualization(c.Virtualization)
AppConfig.Containers = append(AppConfig.Containers, c)
SaveConfig()
}
@@ -536,6 +561,8 @@ func RemoveContainer(id int) bool {
if c.ID == id {
removeSubUserContainerAccess(c.Name, c.UUID)
removeContainerSnapshotMetadata(id)
// Clear snapshot schedule for this container
clearContainerSnapshotSchedule(&AppConfig.Containers[i])
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
SaveConfig()
return true
@@ -544,6 +571,15 @@ func RemoveContainer(id int) bool {
return false
}
func clearContainerSnapshotSchedule(c *Container) {
c.SnapshotScheduleEnabled = false
c.SnapshotScheduleIntervalHours = 0
c.SnapshotScheduleTime = ""
c.SnapshotScheduleLastRun = ""
c.SnapshotScheduleNextRun = ""
c.SnapshotScheduleCreatedBy = ""
}
func AddSnapshot(snapshot Snapshot) {
AppConfig.Snapshots = append(AppConfig.Snapshots, snapshot)
SaveConfig()
@@ -723,6 +759,26 @@ func AddAuditLog(action, target, detail, user string) {
SaveConfig()
}
func AddAuditLogFull(action, target, detail, user, ip, userAgent string, success bool, errMsg string) {
s := success
log := AuditLog{
Time: time.Now().Format("2006-01-02 15:04:05"),
Action: action,
Target: target,
Detail: detail,
User: user,
IP: ip,
UserAgent: userAgent,
Success: &s,
Error: errMsg,
}
AppConfig.AuditLogs = append(AppConfig.AuditLogs, log)
if len(AppConfig.AuditLogs) > 500 {
AppConfig.AuditLogs = AppConfig.AuditLogs[len(AppConfig.AuditLogs)-500:]
}
SaveConfig()
}
// SaveTasks persists the task queue to config
func SaveTasks(tasks []SavedTask) {
AppConfig.Tasks = tasks
@@ -760,6 +816,19 @@ func CleanStaleContainers() {
valid := make([]Container, 0)
changed := false
for _, c := range AppConfig.Containers {
if c.IsKVM() {
if c.DiskImage == "" {
valid = append(valid, c)
continue
}
if _, err := os.Stat(c.DiskImage); os.IsNotExist(err) {
fmt.Printf("Cleaning stale KVM config: %s (disk image not found)\n", c.VirshName())
changed = true
continue
}
valid = append(valid, c)
continue
}
lxcDir := "/var/lib/lxc/" + c.LxcName()
if _, err := os.Stat(lxcDir); os.IsNotExist(err) {
fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName())
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
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-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",
},
{
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-archlinux-current", Name: "Arch Linux KVM",
Distro: "archlinux", Release: "current", Arch: "amd64",
Description: "Arch Linux (Rolling) cloud image for KVM",
URL: "https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2",
},
{
ID: "kvm-fedora-44", Name: "Fedora 44 KVM",
Distro: "fedora", Release: "44", Arch: "amd64",
Description: "Fedora 44 GenericCloud image for KVM",
URL: "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-44-1.7.x86_64.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",
},
}
}
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.
func (m *Manager) StopExpiredContainers(now time.Time) {
for _, container := range config.AppConfig.Containers {
if !isContainerExpired(container, now) {
if container.IsKVM() || !isContainerExpired(container, now) {
continue
}
@@ -53,7 +53,7 @@ func (m *Manager) StopTrafficExceededContainers(now time.Time) {
saved := false
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.Status != "running" {
if c.IsKVM() || c.Status != "running" {
continue
}
+83 -6
View File
@@ -73,11 +73,17 @@ func (m *Manager) WarmRunningContainersSSH() {
containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, container := range containers {
c := container
if c.IsKVM() {
continue
}
status, err := m.GetContainerStatus(c.LxcName())
if err != nil || status != "running" {
continue
}
config.UpdateContainerStatus(c.ID, "running")
if c.IP != "" && m.containerPortListening(c.LxcName(), 22) {
continue
}
m.WarmSSHAsync(c.ID, "running container scan")
}
}
@@ -99,6 +105,11 @@ func (m *Manager) updateAllRates() {
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.IsKVM() {
delete(lastUsage, c.VirshName())
delete(rateCache, c.VirshName())
continue
}
if c.Status != "running" {
delete(lastUsage, c.LxcName())
delete(rateCache, c.LxcName())
@@ -208,6 +219,7 @@ func NewManager() *Manager {
// ContainerConfig defines container creation parameters
type ContainerConfig struct {
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
@@ -342,6 +354,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
ID: id,
UUID: config.NewContainerUUID(),
Name: cfg.Name,
Virtualization: config.VirtualizationLXC,
Template: cfg.TemplateID,
VCPU: cfg.VCPU,
RAMMB: cfg.RAMMB,
@@ -383,6 +396,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
_ = m.cleanupContainerStorage(lxcName)
config.RemoveContainer(id)
return err
}
@@ -459,13 +473,13 @@ IPv6AcceptRA=no
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error {
_ = templateID
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("timed out after 120s, output: %s", string(output))
return fmt.Errorf("timed out after 180s, output: %s", string(output))
}
if err != nil {
return fmt.Errorf("%v, output: %s", err, string(output))
@@ -985,6 +999,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if _, err := os.Stat(marker); err == nil {
return nil
}
m.unmountRootfsChildMounts(rootfsPath)
rootInfo, err := os.Lstat(rootfsPath)
if err != nil {
return err
}
rootStat, ok := rootInfo.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Errorf("failed to read rootfs device for %s", rootfsPath)
}
rootDev := rootStat.Dev
if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error {
if walkErr != nil {
@@ -998,6 +1022,12 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if !ok {
return fmt.Errorf("failed to read uid/gid for %s", path)
}
if path != rootfsPath && stat.Dev != rootDev {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
uid := int(stat.Uid)
gid := int(stat.Gid)
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
@@ -1032,6 +1062,34 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
return nil
}
func (m *Manager) unmountRootfsChildMounts(rootfsPath string) {
rootAbs, err := filepath.Abs(rootfsPath)
if err != nil {
return
}
out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", rootfsPath).Output()
if err != nil {
return
}
targets := strings.Split(strings.TrimSpace(string(out)), "\n")
for i, j := 0, len(targets)-1; i < j; i, j = i+1, j-1 {
targets[i], targets[j] = targets[j], targets[i]
}
for _, target := range targets {
target = strings.TrimSpace(target)
if target == "" {
continue
}
targetAbs, err := filepath.Abs(target)
if err != nil || targetAbs == rootAbs {
continue
}
if strings.HasPrefix(targetAbs, rootAbs+string(os.PathSeparator)) {
exec.Command("umount", "-R", "-l", targetAbs).Run()
}
}
}
func (m *Manager) rootfsShifted(lxcName string) bool {
marker := filepath.Join(m.LxcPath, lxcName, "rootfs", ".clicd-unprivileged-shifted")
_, err := os.Stat(marker)
@@ -1479,11 +1537,20 @@ func (m *Manager) DestroyContainer(id int) error {
}
return fmt.Errorf("container still exists after cleanup with status %s", status)
}
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName)
// Remove snapshot physical files (by container ID, not lxcName)
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id))
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil {
os.RemoveAll(snapshotDir)
}
// Also remove any legacy snapshot dir that used lxcName
legacySnapshotDir := filepath.Join(snapshotBaseDir(), lxcName)
if legacySnapshotDir != snapshotDir {
if err := safePathUnder(legacySnapshotDir, snapshotBaseDir()); err == nil {
os.RemoveAll(legacySnapshotDir)
}
}
if !config.RemoveContainer(id) {
return fmt.Errorf("container destroyed but config entry was not removed: %d", id)
}
@@ -1518,12 +1585,12 @@ func (m *Manager) EnsureSSH(id int) error {
script := sshSetupScript(c.SSHPassword, true)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script)
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("timed out configuring SSH in container %d after 90s; package manager or service startup may be stuck, output: %s", id, string(output))
return fmt.Errorf("timed out configuring SSH in container %d after 180s; package manager or service startup may be stuck, output: %s", id, string(output))
}
if err != nil {
return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output))
@@ -1631,7 +1698,10 @@ install_sshd() {
sleep 3
done
elif command -v apk >/dev/null 2>&1; then
run_timeout 60 apk add --no-cache openssh-server openssh-client shadow iproute2 procps net-tools && return 0
for i in 1 2 3; do
run_timeout 120 apk add --no-cache openssh-server openssh-client shadow iproute2 procps net-tools && return 0
sleep 3
done
elif command -v pacman >/dev/null 2>&1; then
run_timeout 45 pacman -Syu --noconfirm >/dev/null 2>&1 || true
run_timeout 90 pacman -S --noconfirm openssh shadow iproute2 procps-ng net-tools && return 0
@@ -1926,6 +1996,9 @@ func (m *Manager) GetContainerIP(lxcName string) (string, error) {
func (m *Manager) ListContainers() ([]config.Container, error) {
containers := config.AppConfig.Containers
for i := range containers {
if containers[i].IsKVM() {
continue
}
status, err := m.GetContainerStatus(containers[i].LxcName())
if err == nil {
containers[i].Status = status
@@ -2001,6 +2074,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
UUID: config.NewContainerUUID(),
Name: name,
LXCName: lxcName,
Virtualization: config.VirtualizationLXC,
Template: "imported",
VCPU: 1,
RAMMB: 512,
@@ -2519,6 +2593,9 @@ func (m *Manager) AccumulateTraffic() {
delete(lastTrafficSnapshot, c.LxcName())
continue
}
if c.IsKVM() {
continue
}
// Reset if new month
if c.TrafficResetDate != currentMonth {
c.TrafficUsedRX = 0
+24 -11
View File
@@ -18,8 +18,14 @@ func (m *Manager) ApplyPortMappings(id int) error {
return fmt.Errorf("container has no IP")
}
tag := clicdTag(id)
bridge := "lxcbr0"
subnet := "10.0.3.0/24"
if c.IsKVM() {
bridge = "virbr0"
subnet = "192.168.122.0/24"
}
EnsureForwardRules()
EnsureForwardRules(bridge)
m.CleanPortMappings(id)
for _, pm := range c.PortMappings {
@@ -41,8 +47,8 @@ func (m *Manager) ApplyPortMappings(id int) error {
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
}
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run()
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
}
return nil
@@ -50,18 +56,25 @@ func (m *Manager) ApplyPortMappings(id int) error {
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic
func EnsureForwardRules() {
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
func EnsureForwardRules(bridge string) {
if bridge == "" {
bridge = "lxcbr0"
}
rules := [][]string{
{"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"},
{"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"},
{"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"},
{"-i", bridge, "-j", "ACCEPT"},
{"-o", bridge, "-j", "ACCEPT"},
{"-i", bridge, "-o", bridge, "-j", "ACCEPT"},
}
for _, args := range rules {
checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...)
if exec.Command("iptables", checkArgs...).Run() != nil {
exec.Command("iptables", args...).Run()
for {
deleteArgs := append([]string{"-D", "FORWARD"}, args...)
if exec.Command("iptables", deleteArgs...).Run() != nil {
break
}
}
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...)
exec.Command("iptables", insertArgs...).Run()
}
}
+3 -2
View File
@@ -45,7 +45,8 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
now := time.Now()
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName, snapshotID)
// Use container ID instead of lxcName to avoid collision when containers are recreated
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
return config.Snapshot{}, err
}
@@ -206,7 +207,7 @@ func (m *Manager) runDueSnapshotSchedules() {
now := time.Now()
containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, c := range containers {
if !c.SnapshotScheduleEnabled {
if c.IsKVM() || !c.SnapshotScheduleEnabled {
continue
}
nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
+2 -3
View File
@@ -88,9 +88,6 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell)))
mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus)))
mux.HandleFunc("/api/oversell/reclaim", corsMiddleware(api.AdminMiddleware(api.HandleOversellReclaim)))
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
@@ -98,6 +95,8 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
mux.HandleFunc("/api/sub-user/login", corsMiddleware(api.HandleSubUserLogin))
mux.HandleFunc("/api/sub-user/access", corsMiddleware(api.HandleSubUserAccessCode))
mux.HandleFunc("/api/sub-users", corsMiddleware(api.AdminMiddleware(api.HandleSubUserList)))
mux.HandleFunc("/api/sub-users/", corsMiddleware(api.AdminMiddleware(api.HandleSubUserAction)))
mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs)))
mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts)))
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
+4 -1
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.0.8"
Version = "1.0.11"
Repo = "MengMengCode/CLICD"
)
@@ -14,3 +14,6 @@ func Current() string {
+12 -5
View File
@@ -9,6 +9,7 @@ import (
"clicd/internal/api"
"clicd/internal/cli"
"clicd/internal/config"
"clicd/internal/kvm"
"clicd/internal/lxc"
"clicd/internal/server"
@@ -50,18 +51,24 @@ func main() {
// Start security scanner
api.InitScanner()
// Ensure iptables FORWARD rules allow LXC traffic
lxc.EnsureForwardRules()
// Ensure iptables FORWARD rules allow managed bridge traffic.
lxc.EnsureForwardRules("lxcbr0")
lxc.EnsureForwardRules("virbr0")
// Start expiry scanner (stops expired containers every 30s)
// Start expiry scanners (stops expired/over-traffic workloads every 30s)
manager := lxc.NewManager()
kvmManager := kvm.NewManager()
manager.StartExpiryScanner()
kvmManager.StartExpiryScanner()
// Start usage monitor (computes CPU/network/disk rates every 5s)
// Start usage monitors (computes CPU/network/disk rates every 5s)
manager.StartUsageMonitor()
kvmManager.StartUsageMonitor()
kvmManager.StartIPv6Guard()
// Start scheduled snapshot scanner.
// Start scheduled snapshot scanners.
manager.StartSnapshotScheduler()
kvmManager.StartSnapshotScheduler()
// Clean up stale container configs (LXC dir was deleted but config remains)
config.CleanStaleContainers()
+4 -2
View File
@@ -4,7 +4,7 @@ import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Containers from './pages/Containers'
import ContainerDetail from './pages/ContainerDetail'
import Oversell from './pages/Oversell'
import Security from './pages/Security'
import AuditLogs from './pages/AuditLogs'
import ApiIntegration from './pages/ApiIntegration'
@@ -12,6 +12,7 @@ import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement'
import Snapshots from './pages/Snapshots'
import Routing from './pages/Routing'
import SubUserManagement from './pages/SubUserManagement'
import Layout from './components/Layout'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -57,12 +58,13 @@ function App() {
<Route path="containers" element={<Containers />} />
<Route path="images" element={<ImageManagement />} />
<Route path="container/:id" element={<ContainerDetail />} />
<Route path="oversell" element={<Oversell />} />
<Route path="security" element={<Security />} />
<Route path="snapshots" element={<Snapshots />} />
<Route path="routing" element={<Routing />} />
<Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} />
<Route path="sub-users" element={<SubUserManagement />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+161 -30
View File
@@ -7,10 +7,12 @@ interface CreateContainerModalProps {
isOpen: boolean
onClose: () => void
onSuccess: (containers: CreateContainerRequest[]) => void | Promise<void>
existingNames?: string[]
}
const defaultForm: CreateContainerRequest = {
name: '',
virtualization: 'lxc',
template_id: '',
vcpu: 1,
cpu_percent: 100,
@@ -24,12 +26,12 @@ const defaultForm: CreateContainerRequest = {
io_speed_mbps: 0,
extra_ports: [],
port_mapping_count: 2,
snapshot_limit: 3,
snapshot_limit: 1,
assign_ipv6: false,
expires_at: '',
}
export default function CreateContainerModal({ isOpen, onClose, onSuccess }: CreateContainerModalProps) {
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
const dialog = useDialog()
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(false)
@@ -37,17 +39,16 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
const [nameError, setNameError] = useState('')
useEffect(() => {
if (!isOpen) return
getEnabledImages()
getEnabledImages(form.virtualization)
.then((res) => {
const data = res.data.data || []
setTemplates(data)
if (data.length > 0) {
setForm((prev) => ({ ...prev, template_id: prev.template_id || data[0].id }))
}
setForm((prev) => ({ ...prev, template_id: data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '') }))
})
.catch(console.error)
@@ -67,13 +68,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
getHostInfo()
.then((res) => setHostInfo(res.data.data || null))
.catch(() => setHostInfo(null))
}, [isOpen])
}, [isOpen, form.virtualization])
const ipv6Available = !!ipv6Status?.available
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
const autoPorts = useMemo(() => {
const count = Math.max(2, form.port_mapping_count)
@@ -83,18 +85,52 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
// SSH port preview (will be allocated sequentially, starting around 22000+)
const sshPortPreview = 22000
// Find next available batch index to avoid name conflicts
const batchStartIndex = useMemo(() => {
if (batchCount <= 1 || !form.name) return 1
const prefix = `${form.name}-`
let maxIdx = 0
for (const existing of existingNames) {
if (existing.startsWith(prefix)) {
const suffix = existing.slice(prefix.length)
const idx = parseInt(suffix, 10)
if (!isNaN(idx) && idx > maxIdx) {
maxIdx = idx
}
}
}
return maxIdx + 1
}, [form.name, batchCount, existingNames])
const handleNameChange = (value: string) => {
setForm({ ...form, name: value })
if (/\s/.test(value)) {
setNameError('容器名称不能包含空格')
} else if (value && existingNames.includes(value) && batchCount === 1) {
setNameError('该容器名称已存在')
} else {
setNameError('')
}
}
const handleSubmit = async () => {
if (!form.name || !form.template_id) {
dialog.alert('提示', '请填写容器名称并选择系统模板')
return
}
const boundedForm = clampCreateForm(form, maxVCPU, maxRAMMB, maxDiskGB)
if (Object.keys(resourceErrors).length > 0) {
dialog.alert('资源配置有误', '请按红色提示修改 vCPU、内存或磁盘配置')
return
}
const boundedForm = normalizeCreateForm(form)
// Build batch of containers
const containers: CreateContainerRequest[] = []
const startIndex = batchStartIndex
for (let i = 0; i < batchCount; i++) {
const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name
const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : boundedForm.name
containers.push({
...boundedForm,
name,
@@ -137,22 +173,42 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
<input
type="text"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
className={inputClass}
onChange={(event) => handleNameChange(event.target.value)}
className={`${inputClass} ${nameError ? 'border-red-400 focus:ring-red-400 focus:border-red-400' : ''}`}
placeholder="my-container"
required
/>
{nameError && <p className="text-xs text-red-500 mt-1">{nameError}</p>}
</Field>
<Field label="批量创建数量">
<NumberInput value={batchCount} min={1} max={50} onChange={(value) => setBatchCount(Math.max(1, value || 1))} />
</Field>
</div>
{batchCount > 1 && <p className="text-xs text-gray-400"> {batchCount} {form.name}-1 {form.name}-{batchCount}</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="系统模板">
{templates.length === 0 ? (
<div className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
{form.virtualization === 'kvm' ? ' KVM' : ' LXC'}
</div>
) : (
<select
@@ -187,16 +243,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
<div className="grid grid-cols-2 gap-4">
<Field label="vCPU">
<NumberInput value={form.vcpu} min={0.25} max={maxVCPU} step={0.25} onChange={(value) => setForm({ ...form, vcpu: clampVCPU(value, maxVCPU) })} />
<NumberInput
value={form.vcpu}
min={form.virtualization === 'kvm' ? 1 : 0.25}
max={maxVCPU}
step={form.virtualization === 'kvm' ? 1 : 0.25}
invalid={!!resourceErrors.vcpu}
onChange={(value) => setForm({ ...form, vcpu: value })}
/>
{resourceErrors.vcpu && <p className="mt-1 text-xs text-red-500">{resourceErrors.vcpu}</p>}
</Field>
<Field label="内存 (MB)">
<NumberInput value={form.ram_mb} min={128} max={maxRAMMB} step={128} onChange={(value) => setForm({ ...form, ram_mb: clampInt(value, 128, maxRAMMB, 512) })} />
<NumberInput
value={form.ram_mb}
min={128}
max={maxRAMMB}
step={128}
invalid={!!resourceErrors.ram_mb}
onChange={(value) => setForm({ ...form, ram_mb: value })}
/>
{resourceErrors.ram_mb && <p className="mt-1 text-xs text-red-500">{resourceErrors.ram_mb}</p>}
</Field>
</div>
<div className="grid grid-cols-3 gap-3">
<Field label="磁盘 (GB)">
<NumberInput value={form.disk_gb} min={1} max={maxDiskGB} onChange={(value) => setForm({ ...form, disk_gb: clampInt(value, 1, maxDiskGB, 10) })} />
<NumberInput
value={form.disk_gb}
min={1}
max={maxDiskGB}
invalid={!!resourceErrors.disk_gb}
onChange={(value) => setForm({ ...form, disk_gb: value })}
/>
{resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>}
</Field>
<Field label="带宽 (Mbps)">
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} />
@@ -310,44 +389,96 @@ function NumberInput({
min,
max,
step,
invalid,
onChange,
}: {
value: number
min?: number
max?: number
step?: number
invalid?: boolean
onChange: (value: number) => void
}) {
const [draft, setDraft] = useState(Number.isFinite(value) ? String(value) : '')
const [focused, setFocused] = useState(false)
useEffect(() => {
if (!focused) {
setDraft(Number.isFinite(value) ? String(value) : '')
}
}, [focused, value])
return (
<input
type="number"
value={value}
min={min}
max={max}
step={step}
type="text"
inputMode={step && !Number.isInteger(step) ? 'decimal' : 'numeric'}
value={draft}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false)
setDraft(Number.isFinite(value) ? String(value) : '')
}}
onChange={(event) => {
const raw = event.target.value
const value = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
onChange(value)
setDraft(raw)
const next = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
onChange(next)
}}
className={inputClass}
aria-invalid={invalid || undefined}
data-min={min}
data-max={max}
data-step={step}
className={`${inputClass} ${invalid ? 'border-red-400 focus:border-red-400 focus:ring-red-400' : ''}`}
/>
)
}
function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number): CreateContainerRequest {
function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) {
const errors: Partial<Record<'vcpu' | 'ram_mb' | 'disk_gb', string>> = {}
const minVCPU = form.virtualization === 'kvm' ? 1 : 0.25
if (!Number.isFinite(form.vcpu)) {
errors.vcpu = '请输入 vCPU'
} else if (form.vcpu < minVCPU) {
errors.vcpu = `不能小于 ${minVCPU}`
} else if (form.vcpu > maxVCPU) {
errors.vcpu = `不能大于 ${maxVCPU}`
} else if (form.virtualization === 'kvm' && form.vcpu !== Math.round(form.vcpu)) {
errors.vcpu = 'KVM vCPU 必须是整数'
}
if (!Number.isFinite(form.ram_mb)) {
errors.ram_mb = '请输入内存'
} else if (form.ram_mb < 128) {
errors.ram_mb = '不能小于 128 MB'
} else if (maxRAMMB && form.ram_mb > maxRAMMB) {
errors.ram_mb = `不能大于 ${maxRAMMB} MB`
}
if (!Number.isFinite(form.disk_gb)) {
errors.disk_gb = '请输入磁盘'
} else if (form.disk_gb < 1) {
errors.disk_gb = '不能小于 1 GB'
} else if (maxDiskGB && form.disk_gb > maxDiskGB) {
errors.disk_gb = `不能大于 ${maxDiskGB} GB`
}
return errors
}
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
return {
...form,
vcpu: clampVCPU(form.vcpu, maxVCPU),
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512),
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10),
vcpu: form.virtualization === 'kvm' ? Math.round(form.vcpu) : normalizeLXCvCPU(form.vcpu),
ram_mb: Math.round(form.ram_mb),
disk_gb: Math.round(form.disk_gb),
snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3),
}
}
function clampVCPU(value: number, max: number) {
function normalizeLXCvCPU(value: number) {
const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4
return Number(Math.min(Math.max(rounded, 0.25), max).toFixed(2))
return Number(rounded.toFixed(2))
}
function clampInt(value: number, min: number, max?: number, fallback = min) {
+14 -14
View File
@@ -12,7 +12,7 @@ import {
Route,
ScrollText,
Server,
Settings2,
ShieldAlert,
Sun,
UserCog,
@@ -49,7 +49,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
location.pathname.startsWith('/container')
const isImagesPage = location.pathname.startsWith('/images')
const isOversellPage = location.pathname.startsWith('/oversell')
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
const isRoutingPage = location.pathname.startsWith('/routing')
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
@@ -133,18 +133,6 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!isSubUser && (
<>
<button
onClick={() => navigate('/oversell')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isOversellPage
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Settings2 className="w-4 h-4" />
{!collapsed && <span>宿</span>}
</button>
<button
onClick={() => navigate('/security')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
@@ -193,6 +181,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/sub-users')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
location.pathname.startsWith('/sub-users')
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<UserCog className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/api-integration')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
+7 -18
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react'
import api, { APIResponse } from '../services/api'
import { copyToClipboard } from '../utils/clipboard'
interface ApiKeyItem {
id: string
@@ -62,21 +63,12 @@ export default function ApiIntegration() {
} catch { /* ignore */ }
}
const copyKey = () => {
try {
navigator.clipboard.writeText(newKey)
} catch {
const ta = document.createElement('textarea')
ta.value = newKey
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
const copyKey = async () => {
const copied = await copyToClipboard(newKey)
if (copied) {
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}
return (
@@ -256,10 +248,7 @@ export default function ApiIntegration() {
</section>
<section>
<h3 className="font-semibold text-black mb-2"> & </h3>
<Endpoint method="POST" path="/api/oversell" desc="获取/更新超售配置" body='{"cpu_overcommit": 4, "ram_overcommit": 2, "disk_overcommit": 1, "ksm_enabled": true, "swappiness": 10}' />
<Endpoint method="POST" path="/api/oversell/reclaim" desc="触发一次内存回收" />
<Endpoint method="POST" path="/api/oversell/status" desc="超售状态" />
<h3 className="font-semibold text-black mb-2"></h3>
<Endpoint method="POST" path="/api/batch-create" desc="批量创建" body='{"containers": [{...}]}' />
<Endpoint method="POST" path="/api/batch-action" desc="批量操作" body='{"action": "start", "containers": [1, 2, 3]}' />
</section>
+14 -57
View File
@@ -66,6 +66,7 @@ import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
import WebSSHViewer from '../components/WebSSHViewer'
import { RingStat } from '../components/RingStats'
import { copyToClipboard } from '../utils/clipboard'
import ResourceStatsPanel, {
ChartPoint,
ResourceChartConfig,
@@ -358,7 +359,7 @@ export default function ContainerDetail() {
const openReinstall = async () => {
try {
const res = await getEnabledImages()
const res = await getEnabledImages(container?.virtualization || 'lxc')
if (res.data.data) {
setTemplates(res.data.data)
setSelectedTemplate(res.data.data[0]?.id || '')
@@ -619,19 +620,7 @@ export default function ContainerDetail() {
}
const copyText = async (text: string) => {
try {
await copyText(text)
} catch {
// Fallback for HTTP (non-secure context)
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
await copyToClipboard(text)
}
if (loading) {
@@ -676,7 +665,6 @@ export default function ContainerDetail() {
const managementUrl = subUser?.access_code
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
: ''
const managementPassword = subUser?.password || ''
const charts: ResourceChartConfig[] = [
{
title: 'CPU 使用率',
@@ -740,6 +728,7 @@ export default function ContainerDetail() {
</div>
<div className="flex items-center gap-2 flex-wrap mt-2">
<InfoTag color="blue"> {container.template}</InfoTag>
<InfoTag color="slate"> {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
<InfoTag color="emerald"> {container.ip || '-'}</InfoTag>
<InfoTag color="amber">NAT {mappingCount} </InfoTag>
<InfoTag color="violet">{publicHost}:{container.ssh_port}</InfoTag>
@@ -1263,55 +1252,21 @@ export default function ContainerDetail() {
</Modal>
)}
{showSubUser && subUser && false && (
<Modal title="管理链接" onClose={() => setShowSubUser(false)}>
<div className="space-y-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-800">
</div>
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-3">
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black break-all">{managementUrl}</span>
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black">{subUser?.username}</span>
<button onClick={() => copyText(subUser?.username || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black">{managementPassword}</span>
<button onClick={() => copyText(managementPassword)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
</div>
</div>
</div>
<p className="text-xs text-gray-400"> token使</p>
</div>
</Modal>
)}
{showSubUser && subUser && (
<Modal title="管理链接" onClose={() => setShowSubUser(false)}>
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-3">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm space-y-3">
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span>
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black break-all">{managementUrl}</span>
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
<span className="font-mono text-xs text-black dark:text-white break-all">{managementUrl}</span>
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded"><Copy className="w-3 h-3" /></button>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span>
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black">{managementPassword}</span>
<button onClick={() => copyText(managementPassword)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
<span className="font-mono text-xs text-black dark:text-white">{subUser.password || ''}</span>
<button onClick={() => copyText(subUser.password || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded"><Copy className="w-3 h-3" /></button>
</div>
</div>
</div>
@@ -1441,12 +1396,13 @@ function StatusBadge({ running }: { running: boolean }) {
)
}
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet'; children: ReactNode }) {
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate'; children: ReactNode }) {
const classes = {
blue: 'bg-blue-50 text-blue-700 border-blue-100',
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100',
amber: 'bg-amber-50 text-amber-700 border-amber-100',
violet: 'bg-violet-50 text-violet-700 border-violet-100',
slate: 'bg-slate-50 text-slate-700 border-slate-100',
}
return <span className={`px-1.5 py-0.5 border rounded text-[11px] whitespace-nowrap ${classes[color]}`}>{children}</span>
}
@@ -1850,6 +1806,7 @@ function TrafficBar({ container }: { container: Container }) {
function getTemplateIcon(id: string): ReactNode {
const size = 'w-6 h-6'
id = id.startsWith('kvm-') ? id.slice(4) : id
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
+286 -38
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import {
ArrowDown,
@@ -12,6 +12,7 @@ import {
Plus,
RefreshCw,
RotateCcw,
Search,
Server,
Square,
Trash2,
@@ -46,6 +47,12 @@ export default function Containers() {
const [showTasks, setShowTasks] = useState(false)
const [tasks, setTasks] = useState<Task[]>([])
const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({})
const [searchText, setSearchText] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
const [systemFilter, setSystemFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const refreshUsage = useCallback(async (items: Container[]) => {
const targets = items.filter((container) => container.status === 'running')
@@ -102,18 +109,6 @@ export default function Containers() {
})
}
const toggleAll = () => {
const selectableIDs = displayContainers
.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name])
.map((container) => container.id)
if (selected.size === selectableIDs.length) {
setSelected(new Set())
} else {
setSelected(new Set(selectableIDs))
}
}
// Map of container_id -> current task status.
// For create tasks, container_id may be 0 initially but gets set after creation,
// so we also index by container_name as fallback for placeholder items.
@@ -170,6 +165,45 @@ export default function Containers() {
const displayContainers = buildDisplayContainers(containers, queuedCreates, tasks)
const activeTaskCount = tasks.filter((task) => task.status === 'pending' || task.status === 'running').length
const systemOptions = useMemo(() => buildSystemOptions(displayContainers), [displayContainers])
const filteredContainers = useMemo(() => {
return filterContainers(displayContainers, {
search: searchText,
type: typeFilter,
system: systemFilter,
status: statusFilter,
taskStatusMap,
taskNameMap,
})
}, [displayContainers, searchText, typeFilter, systemFilter, statusFilter, tasks])
const totalPages = Math.max(1, Math.ceil(filteredContainers.length / pageSize))
const currentPage = Math.min(page, totalPages)
const pageStart = (currentPage - 1) * pageSize
const pageContainers = filteredContainers.slice(pageStart, pageStart + pageSize)
const selectableIDs = filteredContainers
.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name])
.map((container) => container.id)
const allFilteredSelected = selectableIDs.length > 0 && selectableIDs.every((id) => selected.has(id))
useEffect(() => {
setPage(1)
}, [searchText, typeFilter, systemFilter, statusFilter, pageSize])
const toggleAll = () => {
if (allFilteredSelected) {
setSelected((prev) => {
const next = new Set(prev)
selectableIDs.forEach((id) => next.delete(id))
return next
})
} else {
setSelected((prev) => {
const next = new Set(prev)
selectableIDs.forEach((id) => next.add(id))
return next
})
}
}
const handleCreateQueued = async (items: CreateContainerRequest[]) => {
setQueuedCreates((current) => {
@@ -193,29 +227,16 @@ export default function Containers() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-[180px]">
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1"> {displayContainers.length} {selected.size > 0 && `,已选 ${selected.size}`}</p>
<p className="text-sm text-gray-500 mt-1">
{displayContainers.length}
{filteredContainers.length !== displayContainers.length && `,筛选后 ${filteredContainers.length}`}
{selected.size > 0 && `,已选 ${selected.size}`}
</p>
</div>
<div className="flex items-center gap-2">
{selected.size > 0 && (
<div className="flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5">
<span className="text-xs text-gray-500 mr-1">{selected.size} </span>
<button onClick={() => handleBatchAction('start')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'}
</button>
<button onClick={() => handleBatchAction('stop')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'}
</button>
<button onClick={() => handleBatchAction('restart')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<RotateCcw className="w-3 h-3" />{batchLoading ? '执行中...' : '重启'}
</button>
<button onClick={() => handleBatchAction('delete')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 rounded border border-red-200 disabled:opacity-50 disabled:cursor-not-allowed">
<Trash2 className="w-3 h-3" />{batchLoading ? '执行中...' : '删除'}
</button>
</div>
)}
<div className="flex flex-wrap items-center justify-end gap-2">
<button
onClick={handleRefreshList}
disabled={refreshing}
@@ -249,6 +270,84 @@ export default function Containers() {
</div>
</div>
{displayContainers.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-[260px]">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
<input
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
className="h-8 w-full rounded-md border border-gray-300 bg-white pl-8 pr-2 text-xs text-black outline-none focus:border-black focus:ring-2 focus:ring-black"
placeholder="搜索名称、ID、UUID、IP"
/>
</div>
<select
value={typeFilter}
onChange={(event) => setTypeFilter(event.target.value)}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="类型筛选"
>
<option value="all"></option>
<option value="lxc">LXC</option>
<option value="kvm">KVM</option>
</select>
<select
value={systemFilter}
onChange={(event) => setSystemFilter(event.target.value)}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="系统筛选"
>
<option value="all"></option>
{systemOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="状态筛选"
>
<option value="all"></option>
<option value="running">线</option>
<option value="stopped">线</option>
<option value="task"></option>
<option value="creating"></option>
<option value="failed"></option>
</select>
<select
value={pageSize}
onChange={(event) => setPageSize(Number(event.target.value))}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="每页数量"
>
<option value={10}>10 / </option>
<option value={20}>20 / </option>
<option value={50}>50 / </option>
</select>
</div>
{selected.size > 0 && (
<div className="flex flex-wrap items-center justify-end gap-1.5">
<span className="text-xs text-gray-500">{selected.size} </span>
<button onClick={() => handleBatchAction('start')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'}
</button>
<button onClick={() => handleBatchAction('stop')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'}
</button>
<button onClick={() => handleBatchAction('restart')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
<RotateCcw className="w-3 h-3" />{batchLoading ? '执行中...' : '重启'}
</button>
<button onClick={() => handleBatchAction('delete')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-red-600 hover:bg-red-50 rounded border border-red-200 disabled:opacity-50 disabled:cursor-not-allowed">
<Trash2 className="w-3 h-3" />{batchLoading ? '执行中...' : '删除'}
</button>
</div>
)}
</div>
)}
{displayContainers.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-lg p-12 text-center">
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
@@ -260,14 +359,15 @@ export default function Containers() {
) : (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px]">
<table className="w-full min-w-[1260px]">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="w-10 px-3 py-3">
{!isSubUser && (
<input
type="checkbox"
checked={displayContainers.length > 0 && selected.size === displayContainers.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name]).length}
checked={allFilteredSelected}
disabled={selectableIDs.length === 0}
onChange={toggleAll}
className="w-4 h-4 rounded border-gray-300 text-black focus:ring-black accent-black"
/>
@@ -277,6 +377,7 @@ export default function Containers() {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead icon><Cpu className="w-3.5 h-3.5" />CPU</TableHead>
<TableHead icon><MemoryStick className="w-3.5 h-3.5" />MEMORY</TableHead>
<TableHead icon><HardDrive className="w-3.5 h-3.5" />DISK</TableHead>
@@ -287,7 +388,7 @@ export default function Containers() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{displayContainers.map((container) => {
{pageContainers.map((container) => {
const isRunning = container.status === 'running'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const isPlaceholder = !!container.isPlaceholder
@@ -337,6 +438,9 @@ export default function Containers() {
{getTemplateName(container.template)}
</span>
</td>
<td className="px-2.5 py-2 align-top">
<RuntimeBadge runtime={container.virtualization || 'lxc'} />
</td>
<td className="px-2.5 py-2 align-top">
<ProgressCell pct={cpuPct} />
</td>
@@ -398,10 +502,54 @@ export default function Containers() {
</tbody>
</table>
</div>
{filteredContainers.length === 0 ? (
<div className="border-t border-gray-100 px-4 py-10 text-center text-sm text-gray-500">
</div>
) : (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-100 px-4 py-3">
<div className="text-xs text-gray-500">
{pageStart + 1}-{Math.min(pageStart + pageSize, filteredContainers.length)} / {filteredContainers.length}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(1)}
disabled={currentPage === 1}
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
</button>
<button
onClick={() => setPage((value) => Math.max(1, value - 1))}
disabled={currentPage === 1}
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
</button>
<span className="px-2 text-xs text-gray-500">
{currentPage} / {totalPages}
</span>
<button
onClick={() => setPage((value) => Math.min(totalPages, value + 1))}
disabled={currentPage === totalPages}
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
</button>
<button
onClick={() => setPage(totalPages)}
disabled={currentPage === totalPages}
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
</button>
</div>
</div>
)}
</div>
)}
<CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} />
<CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} existingNames={containers.map(c => c.name)} />
{showTasks && (
<TaskQueueModal
tasks={tasks}
@@ -484,6 +632,15 @@ function StatusBadge({ running, task, placeholder }: { running: boolean; task?:
)
}
function RuntimeBadge({ runtime }: { runtime: string }) {
const normalized = runtime === 'kvm' ? 'kvm' : 'lxc'
return (
<span className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${normalized === 'kvm' ? 'bg-indigo-50 text-indigo-700' : 'bg-gray-100 text-gray-700'}`}>
{normalized.toUpperCase()}
</span>
)
}
function buildDisplayContainers(
containers: Container[],
queuedCreates: Record<string, CreateContainerRequest>,
@@ -515,6 +672,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
id: 0,
uuid: '',
name: cfg.name,
virtualization: cfg.virtualization || 'lxc',
template: cfg.template_id,
vcpu: cfg.vcpu,
ram_mb: cfg.ram_mb,
@@ -585,6 +743,90 @@ function hasActiveTasks(tasks: Task[]) {
return tasks.some((task) => task.status === 'pending' || task.status === 'running')
}
type ContainerFilters = {
search: string
type: string
system: string
status: string
taskStatusMap: Record<number, Task>
taskNameMap: Record<string, Task>
}
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
const keyword = filters.search.trim().toLowerCase()
return containers.filter((container) => {
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
return false
}
if (filters.type !== 'all' && (container.virtualization || 'lxc') !== filters.type) {
return false
}
if (filters.status !== 'all' && getContainerStatusFilterValue(container, task) !== filters.status) {
return false
}
if (!keyword) return true
const fields = [
String(container.id),
container.name,
container.uuid,
container.ip,
container.ipv6,
container.template,
container.virtualization || 'lxc',
getTemplateName(container.template),
getSystemFilterLabel(getSystemFilterValue(container.template)),
String(container.ssh_port || ''),
]
return fields.some((field) => field.toLowerCase().includes(keyword))
})
}
function buildSystemOptions(containers: DisplayContainer[]) {
const systems = new Map<string, string>()
for (const container of containers) {
const value = getSystemFilterValue(container.template)
systems.set(value, getSystemFilterLabel(value))
}
return Array.from(systems.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label))
}
function getSystemFilterValue(template: string) {
const normalized = template.startsWith('kvm-') ? template.slice(4) : template
if (normalized.startsWith('ubuntu')) return 'ubuntu'
if (normalized.startsWith('debian')) return 'debian'
if (normalized.startsWith('alpine')) return 'alpine'
if (normalized.startsWith('centos')) return 'centos'
if (normalized.startsWith('archlinux')) return 'archlinux'
if (normalized.startsWith('fedora')) return 'fedora'
if (normalized.startsWith('rockylinux')) return 'rockylinux'
return normalized || 'unknown'
}
function getSystemFilterLabel(system: string) {
const labels: Record<string, string> = {
ubuntu: 'Ubuntu',
debian: 'Debian',
alpine: 'Alpine',
centos: 'CentOS',
archlinux: 'Arch Linux',
fedora: 'Fedora',
rockylinux: 'Rocky Linux',
unknown: '未知系统',
}
return labels[system] || system
}
function getContainerStatusFilterValue(container: DisplayContainer, task?: Task) {
if (task?.status === 'failed') return 'failed'
if (container.isPlaceholder || task?.type === 'create') return 'creating'
if (task && task.status !== 'done' && task.status !== 'failed') return 'task'
return container.status === 'running' ? 'running' : 'stopped'
}
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
if (task.type === 'create' && task.status === 'done') return '初始化完成'
@@ -697,12 +939,18 @@ function getTemplateName(id: string) {
'archlinux-current': 'Arch Linux',
'fedora-44': 'Fedora 44',
'rockylinux-10': 'Rocky 10',
'kvm-ubuntu-noble': 'Ubuntu 24.04',
'kvm-ubuntu-jammy': 'Ubuntu 22.04',
'kvm-debian-bookworm': 'Debian 12',
'kvm-debian-bullseye': 'Debian 11',
'kvm-rockylinux-9': 'Rocky 9',
}
return map[id] || id
}
function getTemplateIcon(id: string): ReactNode {
const size = 'w-4 h-4'
id = id.startsWith('kvm-') ? id.slice(4) : id
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
+59 -4
View File
@@ -80,6 +80,8 @@ export default function ImageManagement() {
}
const downloadedCount = images.filter((img) => img.downloaded).length
const lxcImages = images.filter((img) => img.type === 'lxc')
const kvmImages = images.filter((img) => img.type === 'kvm')
if (loading) {
return (
@@ -95,7 +97,7 @@ export default function ImageManagement() {
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1">
LXC
LXC / KVM /
{downloadedCount}/{images.length}
</p>
</div>
@@ -115,6 +117,58 @@ export default function ImageManagement() {
</div>
)}
<ImageTable
title="LXC 容器镜像"
images={lxcImages}
actionLoading={actionLoading}
downloadedCount={lxcImages.filter((img) => img.downloaded).length}
totalCount={lxcImages.length}
onDownload={handleDownload}
onDelete={handleDelete}
onToggle={handleToggle}
/>
<ImageTable
title="KVM 虚拟机镜像"
images={kvmImages}
actionLoading={actionLoading}
downloadedCount={kvmImages.filter((img) => img.downloaded).length}
totalCount={kvmImages.length}
onDownload={handleDownload}
onDelete={handleDelete}
onToggle={handleToggle}
/>
</div>
)
}
function ImageTable({
title,
images,
actionLoading,
downloadedCount,
totalCount,
onDownload,
onDelete,
onToggle,
}: {
title: string
images: ImageInfo[]
actionLoading: string | null
downloadedCount: number
totalCount: number
onDownload: (id: string) => void
onDelete: (id: string) => void
onToggle: (id: string, enabled: boolean) => void
}) {
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-800">{title}</h2>
<span className="text-xs text-gray-400">
{downloadedCount}/{totalCount}
</span>
</div>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
@@ -172,7 +226,7 @@ export default function ImageManagement() {
<div className="flex items-center justify-end gap-2">
{!img.downloaded && !img.downloading && (
<button
onClick={() => handleDownload(img.id)}
onClick={() => onDownload(img.id)}
disabled={isBusy}
className="inline-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 disabled:opacity-50"
>
@@ -195,7 +249,7 @@ export default function ImageManagement() {
{img.downloaded && (
<>
<button
onClick={() => handleToggle(img.id, img.enabled)}
onClick={() => onToggle(img.id, img.enabled)}
disabled={isBusy}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
img.enabled
@@ -207,7 +261,7 @@ export default function ImageManagement() {
{img.enabled ? '启用' : '禁用'}
</button>
<button
onClick={() => handleDelete(img.id)}
onClick={() => onDelete(img.id)}
disabled={isBusy}
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md border border-red-200 text-red-600 hover:bg-red-50 transition-colors text-xs font-medium disabled:opacity-50"
title="删除镜像缓存"
@@ -264,6 +318,7 @@ function StatusBadge({ img }: { img: ImageInfo }) {
function getTemplateIcon(id: string): ReactNode {
const size = 'w-5 h-5'
id = id.startsWith('kvm-') ? id.slice(4) : id
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
-490
View File
@@ -1,490 +0,0 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { Cpu, MemoryStick, HardDrive, RefreshCw, Save, RotateCcw } from 'lucide-react'
import {
getOversell,
updateOversell,
getOversellStatus,
getHostInfo,
reclaimMemory,
HostInfo,
OversellConfig,
OversellStatus,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { formatMB } from '../utils/labels'
export default function Oversell() {
const dialog = useDialog()
const [config, setConfig] = useState<OversellConfig | null>(null)
const [status, setStatus] = useState<OversellStatus | null>(null)
const [host, setHost] = useState<HostInfo | null>(null)
const [estimateSpec, setEstimateSpec] = useState({ vcpu: 1, ramMb: 1024, diskGb: 10 })
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [reclaiming, setReclaiming] = useState(false)
const fetchData = useCallback(async () => {
try {
const [cfgRes, stRes, hostRes] = await Promise.all([
getOversell(),
getOversellStatus(),
getHostInfo(),
])
if (cfgRes.data.data) setConfig(cfgRes.data.data)
if (stRes.data.data) setStatus(stRes.data.data)
if (hostRes.data.data) setHost(hostRes.data.data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
const handleSave = async () => {
if (!config) return
if (config.cpu_overcommit < 1 || config.ram_overcommit < 1 || config.disk_overcommit < 1) {
await dialog.alert('参数错误', '超售倍数不能小于 1。')
return
}
if (config.swappiness < 0 || config.swappiness > 100) {
await dialog.alert('参数错误', 'Swap 倾向必须在 0 到 100 之间。')
return
}
setSaving(true)
try {
await updateOversell(config)
await fetchData()
await dialog.alert('已应用', '宿主机控制参数已保存。')
} catch (err) {
console.error(err)
await dialog.alert('保存失败', getErrorMessage(err, '请检查宿主机权限或稍后重试。'))
} finally {
setSaving(false)
}
}
const handleReclaimMemory = async () => {
setReclaiming(true)
try {
const res = await reclaimMemory()
await fetchData()
const result = res.data.data
const errors = result?.errors?.length ? `\n失败: ${result.errors.join('; ')}` : ''
await dialog.alert(
'回收已触发',
`已处理 ${result?.attempted || 0} 个运行中容器,成功 ${result?.reclaimed || 0} 个,不支持 ${result?.unsupported || 0} 个。${errors}`
)
} catch (err) {
console.error(err)
await dialog.alert('回收失败', getErrorMessage(err, '请检查宿主机是否支持 cgroup v2 memory.reclaim。'))
} finally {
setReclaiming(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
</div>
)
}
if (!config) return null
const estimate = host ? buildCapacityEstimate(host, status, config, estimateSpec) : null
const ksmSupported = status?.ksm_supported !== false
const reclaimSupported = status?.reclaim_supported !== false
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-black">宿</h1>
<p className="text-sm text-gray-500 mt-1">KSM 宿</p>
</div>
<button
onClick={fetchData}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<ResourceCard
icon={<Cpu className="w-3.5 h-3.5" />}
label="已分配 vCPU"
value={String(status?.allocated_cpu || 0)}
hint={`超售倍数: ${config.cpu_overcommit}x`}
/>
<ResourceCard
icon={<MemoryStick className="w-3.5 h-3.5" />}
label="已分配内存"
value={formatMB(status?.allocated_ram_mb || 0)}
hint={`超售倍数: ${config.ram_overcommit}x`}
/>
<ResourceCard
icon={<HardDrive className="w-3.5 h-3.5" />}
label="已分配磁盘"
value={`${status?.allocated_disk_gb || 0} GB`}
hint={`超售倍数: ${config.disk_overcommit}x`}
/>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4"></h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<SliderField
label="CPU 超售"
value={config.cpu_overcommit}
min={1}
max={32}
suffix="x"
onChange={(v) => setConfig({ ...config, cpu_overcommit: v })}
hint="只用于容量估算,不改变单台容器限制"
/>
<SliderField
label="内存超售"
value={config.ram_overcommit}
min={1}
max={16}
suffix="x"
onChange={(v) => setConfig({ ...config, ram_overcommit: v })}
hint="只用于容量估算,不改变单台容器限制"
/>
<SliderField
label="磁盘超售"
value={config.disk_overcommit}
min={1}
max={16}
suffix="x"
onChange={(v) => setConfig({ ...config, disk_overcommit: v })}
hint="用于容量预估,实际写入仍受文件系统限制"
/>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center justify-between gap-4 mb-4">
<h2 className="text-sm font-semibold text-black"></h2>
<span className="text-xs text-gray-500"></span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-5">
<div className="grid grid-cols-3 gap-3">
<NumberField
label="vCPU"
value={estimateSpec.vcpu}
min={0.25}
step={0.25}
onChange={(value) => setEstimateSpec({ ...estimateSpec, vcpu: value })}
/>
<NumberField
label="内存 MB"
value={estimateSpec.ramMb}
min={128}
step={128}
onChange={(value) => setEstimateSpec({ ...estimateSpec, ramMb: value })}
/>
<NumberField
label="磁盘 GB"
value={estimateSpec.diskGb}
min={1}
onChange={(value) => setEstimateSpec({ ...estimateSpec, diskGb: value })}
/>
</div>
{estimate && (
<div className="grid grid-cols-1 xl:grid-cols-[220px_1fr] gap-4">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="text-xs text-gray-500"></div>
<div className="mt-1 text-3xl font-bold text-black">{estimate.remainingCount}</div>
<div className="mt-1 text-xs text-gray-400">
{estimate.totalCount} {estimate.bottleneckLabel}
</div>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{estimate.rows.map((row) => (
<tr key={row.label}>
<td className="px-3 py-2 text-gray-700">{row.label}</td>
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.actual}</td>
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.capacity}</td>
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.allocated}</td>
<td className="px-3 py-2 text-right font-semibold text-black">{row.remainingCount}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4"></h2>
<div className="space-y-4">
<ToggleRow
label="KSM 合并"
desc="合并容器间相同内存页,减少实际内存占用"
value={config.ksm_enabled && ksmSupported}
disabled={!ksmSupported}
onChange={(v) => setConfig({ ...config, ksm_enabled: v })}
extra={ksmSupported ? `已合并 ${status?.ksm_pages || 0}` : '当前内核不支持 KSM'}
/>
<SliderField
label="Swap 倾向"
value={config.swappiness}
min={0}
max={100}
suffix=""
onChange={(v) => setConfig({ ...config, swappiness: v })}
hint="写入 /proc/sys/vm/swappiness,值越低越少使用 swap"
/>
<ActionRow
title="立即回收缓存"
desc={reclaimSupported ? '对运行中容器触发一次 cgroup v2 memory.reclaim' : '当前环境未检测到 memory.reclaim'}
disabled={!reclaimSupported || reclaiming}
busy={reclaiming}
onClick={handleReclaimMemory}
/>
</div>
</div>
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-sm font-medium disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? '保存中...' : '应用设置'}
</button>
</div>
</div>
)
}
function ResourceCard({ icon, label, value, hint }: {
icon: ReactNode
label: string
value: string
hint: string
}) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-xs text-gray-500 mb-1">
{icon}{label}
</div>
<div className="text-2xl font-bold text-black">{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{hint}</div>
</div>
)
}
function SliderField({ label, value, min, max, suffix, onChange, hint }: {
label: string
value: number
min: number
max: number
suffix: string
onChange: (v: number) => void
hint?: string
}) {
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">{label}</span>
<span className="text-sm text-gray-500 font-mono">{value}{suffix}</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10) || min)}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black"
/>
<div className="flex justify-between text-[10px] text-gray-300 mt-0.5">
<span>{min}{suffix}</span><span>{max}{suffix}</span>
</div>
{hint && <div className="text-[10px] text-gray-400 mt-1">{hint}</div>}
</div>
)
}
function NumberField({ label, value, min, step = 1, onChange }: {
label: string
value: number
min: number
step?: number
onChange: (value: number) => void
}) {
return (
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-gray-600">{label}</span>
<input
type="number"
min={min}
step={step}
value={value}
onChange={(e) => {
const parsed = step % 1 === 0 ? parseInt(e.target.value, 10) : parseFloat(e.target.value)
onChange(Math.max(min, Number.isFinite(parsed) ? parsed : min))
}}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black focus:border-black focus:outline-none focus:ring-2 focus:ring-black"
/>
</label>
)
}
function ToggleRow({ label, desc, value, disabled = false, onChange, extra }: {
label: string
desc: string
value: boolean
disabled?: boolean
onChange: (v: boolean) => void
extra?: string
}) {
return (
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm font-medium text-gray-700">{label}</div>
<div className="text-xs text-gray-400">{desc}</div>
{extra && <div className="text-xs text-gray-500 mt-0.5">{extra}</div>}
</div>
<label className={`relative inline-flex items-center ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}>
<input
type="checkbox"
checked={value}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-300 peer-checked:bg-black rounded-full after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4"></div>
</label>
</div>
)
}
function ActionRow({ title, desc, disabled, busy, onClick }: {
title: string
desc: string
disabled: boolean
busy: boolean
onClick: () => void
}) {
return (
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm font-medium text-gray-700">{title}</div>
<div className="text-xs text-gray-400">{desc}</div>
</div>
<button
onClick={onClick}
disabled={disabled}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
<RotateCcw className={`w-4 h-4 ${busy ? 'animate-spin' : ''}`} />
{busy ? '回收中...' : '执行'}
</button>
</div>
)
}
type EstimateSpec = {
vcpu: number
ramMb: number
diskGb: number
}
type EstimateRow = {
label: string
actual: string
capacity: string
allocated: string
totalCount: number
remainingCount: number
}
function buildCapacityEstimate(
host: HostInfo,
status: OversellStatus | null,
config: OversellConfig,
spec: EstimateSpec
) {
const cpuCapacity = host.cpu.cores * config.cpu_overcommit
const ramCapacity = host.ram.total_mb * config.ram_overcommit
const diskCapacity = host.disk.total_gb * config.disk_overcommit
const allocatedCPU = status?.allocated_cpu || 0
const allocatedRAM = status?.allocated_ram_mb || 0
const allocatedDisk = status?.allocated_disk_gb || 0
const rows: EstimateRow[] = [
{
label: 'CPU',
actual: `${host.cpu.cores}`,
capacity: `${cpuCapacity} vCPU`,
allocated: `${allocatedCPU} vCPU`,
totalCount: safeFloor(cpuCapacity / spec.vcpu),
remainingCount: safeFloor((cpuCapacity - allocatedCPU) / spec.vcpu),
},
{
label: '内存',
actual: formatMB(Number(host.ram.total_mb)),
capacity: formatMB(ramCapacity),
allocated: formatMB(allocatedRAM),
totalCount: safeFloor(ramCapacity / spec.ramMb),
remainingCount: safeFloor((ramCapacity - allocatedRAM) / spec.ramMb),
},
{
label: '磁盘',
actual: `${host.disk.total_gb} GB`,
capacity: `${diskCapacity} GB`,
allocated: `${allocatedDisk} GB`,
totalCount: safeFloor(diskCapacity / spec.diskGb),
remainingCount: safeFloor((diskCapacity - allocatedDisk) / spec.diskGb),
},
]
const totalCount = Math.min(...rows.map((row) => row.totalCount))
const remainingCount = Math.min(...rows.map((row) => row.remainingCount))
const bottleneck = rows.reduce((current, row) => row.remainingCount < current.remainingCount ? row : current, rows[0])
return {
rows,
totalCount,
remainingCount,
bottleneckLabel: bottleneck.label,
}
}
function safeFloor(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0
return Math.floor(value)
}
function getErrorMessage(err: unknown, fallback: string): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const response = (err as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
+90 -20
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Network, RefreshCw, Route, Server } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Network, RefreshCw, Route, Search, Server, X } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { getRoutingInfo, RoutingInfo } from '../services/api'
import { getRoutingInfo, RoutingInfo, NAT4Route, IPv6Route } from '../services/api'
export default function Routing() {
const navigate = useNavigate()
@@ -10,6 +10,8 @@ export default function Routing() {
const [refreshing, setRefreshing] = useState(false)
const [nat4Page, setNat4Page] = useState(1)
const [ipv6Page, setIPv6Page] = useState(1)
const [nat4Search, setNat4Search] = useState('')
const [ipv6Search, setIPv6Search] = useState('')
const fetchData = useCallback(async () => {
try {
@@ -25,6 +27,39 @@ export default function Routing() {
useEffect(() => { fetchData() }, [fetchData])
const nat4Mappings = routing?.nat4_mappings || []
const ipv6Assignments = routing?.ipv6_assignments || []
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
// Filter helpers
const matchesNat4Search = (m: NAT4Route, query: string) => {
if (!query) return true
const q = query.toLowerCase()
return (
String(m.host_port).includes(q) ||
String(m.container_port).includes(q) ||
m.container_name.toLowerCase().includes(q) ||
m.lxc_name.toLowerCase().includes(q) ||
(m.ip || '').toLowerCase().includes(q)
)
}
const matchesIPv6Search = (item: IPv6Route, query: string) => {
if (!query) return true
const q = query.toLowerCase()
return (
(item.address || '').toLowerCase().includes(q) ||
item.container_name.toLowerCase().includes(q) ||
item.lxc_name.toLowerCase().includes(q)
)
}
const filteredNat4 = useMemo(() => nat4Mappings.filter(m => matchesNat4Search(m, nat4Search)), [nat4Mappings, nat4Search])
const filteredIPv6 = useMemo(() => ipv6Assignments.filter(m => matchesIPv6Search(m, ipv6Search)), [ipv6Assignments, ipv6Search])
// Reset page on search change
useEffect(() => { setNat4Page(1) }, [nat4Search])
useEffect(() => { setIPv6Page(1) }, [ipv6Search])
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -33,16 +68,13 @@ export default function Routing() {
)
}
const nat4Mappings = routing?.nat4_mappings || []
const ipv6Assignments = routing?.ipv6_assignments || []
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
const pageSize = 10
const nat4TotalPages = Math.max(1, Math.ceil(nat4Mappings.length / pageSize))
const ipv6TotalPages = Math.max(1, Math.ceil(ipv6Assignments.length / pageSize))
const nat4TotalPages = Math.max(1, Math.ceil(filteredNat4.length / pageSize))
const ipv6TotalPages = Math.max(1, Math.ceil(filteredIPv6.length / pageSize))
const currentNat4Page = Math.min(nat4Page, nat4TotalPages)
const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages)
const pagedNat4Mappings = nat4Mappings.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
const pagedIPv6Assignments = ipv6Assignments.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize)
const pagedNat4Mappings = filteredNat4.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
const pagedIPv6Assignments = filteredIPv6.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize)
return (
<div className="space-y-5">
@@ -80,10 +112,29 @@ export default function Routing() {
/>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<div className="border-b border-gray-200 px-4 py-3">
<div className="text-sm font-medium text-black">NAT4 </div>
<div className="mt-1 text-xs text-gray-500"> {nat4Mappings.length} </div>
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-black dark:text-white">NAT4 </div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{nat4Search ? `搜索 "${nat4Search}" 结果 ${filteredNat4.length} 条,` : ''} {nat4Mappings.length}
</div>
</div>
<div className="relative w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
type="text"
value={nat4Search}
onChange={e => setNat4Search(e.target.value)}
placeholder="搜索端口/容器..."
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
/>
{nat4Search && (
<button onClick={() => setNat4Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
{nat4Mappings.length === 0 ? (
<EmptyState icon={<Route className="h-7 w-7 text-gray-400" />} text="暂无 NAT4 端口映射" />
@@ -130,7 +181,7 @@ export default function Routing() {
<Pagination
page={currentNat4Page}
totalPages={nat4TotalPages}
totalItems={nat4Mappings.length}
totalItems={filteredNat4.length}
pageSize={pageSize}
onPageChange={setNat4Page}
/>
@@ -138,10 +189,29 @@ export default function Routing() {
)}
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<div className="border-b border-gray-200 px-4 py-3">
<div className="text-sm font-medium text-black">IPv6 </div>
<div className="mt-1 text-xs text-gray-500"> {ipv6Assignments.length} </div>
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-black dark:text-white">IPv6 </div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{ipv6Search ? `搜索 "${ipv6Search}" 结果 ${filteredIPv6.length} 条,` : ''} {ipv6Assignments.length}
</div>
</div>
<div className="relative w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
type="text"
value={ipv6Search}
onChange={e => setIPv6Search(e.target.value)}
placeholder="搜索地址/容器..."
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
/>
{ipv6Search && (
<button onClick={() => setIPv6Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
{ipv6Assignments.length === 0 ? (
<EmptyState icon={<Network className="h-7 w-7 text-gray-400" />} text="暂无 IPv6 地址分配" />
@@ -184,7 +254,7 @@ export default function Routing() {
<Pagination
page={currentIPv6Page}
totalPages={ipv6TotalPages}
totalItems={ipv6Assignments.length}
totalItems={filteredIPv6.length}
pageSize={pageSize}
onPageChange={setIPv6Page}
/>
+35 -2
View File
@@ -1,13 +1,16 @@
import { useCallback, useEffect, useState } from 'react'
import { Camera, RefreshCw, Server } from 'lucide-react'
import { Camera, RefreshCw, Server, Trash2 } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { getSnapshots, Snapshot } from '../services/api'
import { deleteContainerSnapshot, getSnapshots, Snapshot } from '../services/api'
import { useDialog } from '../components/Dialog'
export default function Snapshots() {
const navigate = useNavigate()
const dialog = useDialog()
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [deleting, setDeleting] = useState<string | null>(null)
const fetchData = useCallback(async () => {
try {
@@ -23,6 +26,25 @@ export default function Snapshots() {
useEffect(() => { fetchData() }, [fetchData])
const handleDelete = async (snapshot: Snapshot) => {
const confirmed = await dialog.confirm(
'删除快照',
`确认删除容器 ${snapshot.container_name} 的快照吗?此操作不可恢复。`
)
if (!confirmed) return
setDeleting(snapshot.id)
try {
await deleteContainerSnapshot(snapshot.container_id, snapshot.id)
setSnapshots(prev => prev.filter(s => s.id !== snapshot.id))
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('删除失败', error.response?.data?.message || '请稍后重试')
} finally {
setDeleting(null)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -66,6 +88,7 @@ export default function Snapshots() {
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-center font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
@@ -89,6 +112,16 @@ export default function Snapshots() {
</td>
<td className="px-4 py-3 text-gray-600">{snapshot.created_by || '-'}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleDelete(snapshot)}
disabled={deleting === snapshot.id}
className="inline-flex items-center justify-center p-1.5 rounded text-red-500 hover:bg-red-50 transition-colors disabled:opacity-50"
title="删除快照"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
+367
View File
@@ -0,0 +1,367 @@
import { useCallback, useEffect, useState } from 'react'
import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react'
import { useDialog } from '../components/Dialog'
import api, { AuditLog, LoginLog } from '../services/api'
import { copyToClipboard } from '../utils/clipboard'
interface SubUserItem {
id: string
username: string
container_names: string[]
container_uuids: string[]
container_name: string
container_uuid: string
access_code: string
password?: string
created_at: string
last_login: string
last_login_ip: string
last_login_ua: string
}
interface AuditLogExt extends AuditLog {
ip?: string
user_agent?: string
success?: boolean
error?: string
}
export default function SubUserManagement() {
const dialog = useDialog()
const [users, setUsers] = useState<SubUserItem[]>([])
const [loading, setLoading] = useState(true)
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
const [modalTitle, setModalTitle] = useState('')
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
const [rotatingPassword, setRotatingPassword] = useState(false)
const [logPage, setLogPage] = useState(1)
const [logPageSize, setLogPageSize] = useState(10)
const fetchUsers = useCallback(async () => {
try {
const res = await api.get<{ success: boolean; data: SubUserItem[] }>('/sub-users')
setUsers(res.data.data || [])
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchUsers() }, [fetchUsers])
const managementUrl = (user: SubUserItem) => `${window.location.origin}/login?code=${user.access_code}`
const copyText = async (text: string) => {
await copyToClipboard(text)
}
const rotatePassword = async (user: SubUserItem) => {
setRotatingPassword(true)
try {
const res = await api.post(`/sub-users/${user.id}/rotate-password`)
const data = res.data.data
const updatedUser = {
...user,
username: data?.username || user.username,
access_code: data?.access_code || user.access_code,
password: data?.password || '',
}
setUsers((prev) => prev.map((item) => (item.id === user.id ? updatedUser : item)))
setPasswordUser(updatedUser)
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('轮换失败', error.response?.data?.message || '请稍后重试')
} finally {
setRotatingPassword(false)
}
}
const showAuditLogs = async (user: SubUserItem) => {
try {
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
setAuditLogs(res.data.data || [])
setLoginLogs(null)
setModalTitle(`${user.username} - 操作日志`)
setLogPage(1)
} catch {
dialog.alert('错误', '获取操作日志失败')
}
}
const showLoginLogs = async (user: SubUserItem) => {
try {
const res = await api.get(`/sub-users/${user.id}/login-logs`)
setLoginLogs(res.data.data || [])
setAuditLogs(null)
setModalTitle(`${user.username} - 登录日志`)
setLogPage(1)
} catch {
dialog.alert('错误', '获取登录日志失败')
}
}
const closeModal = () => {
setAuditLogs(null)
setLoginLogs(null)
}
const currentLogTotal = auditLogs?.length ?? loginLogs?.length ?? 0
const logTotalPages = Math.max(1, Math.ceil(currentLogTotal / logPageSize))
const currentLogPage = Math.min(logPage, logTotalPages)
const logStart = (currentLogPage - 1) * logPageSize
const currentAuditLogs = auditLogs?.slice(logStart, logStart + logPageSize)
const currentLoginLogs = loginLogs?.slice(logStart, logStart + logPageSize)
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
</div>
)
}
return (
<div className="space-y-5">
<div>
<h1 className="text-xl font-semibold text-black dark:text-white"></h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> {users.length} </p>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
{users.length === 0 ? (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-800">
<UserCog className="h-7 w-7 text-gray-400" />
</div>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300"></div>
</div>
) : (
<table className="w-full min-w-[820px] text-sm">
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400">
<tr>
<th className="px-4 py-3 text-left font-medium w-12">#</th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium">UUID</th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-center font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{users.map((user, index) => (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-3 text-gray-400 dark:text-gray-500">{index + 1}</td>
<td className="px-4 py-3 font-medium text-black dark:text-white">{user.container_name || '-'}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{user.container_uuid || '-'}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{user.last_login ? (
<div>
<div className="text-xs">{user.last_login}</div>
<div className="text-xs text-gray-400 dark:text-gray-500">{user.last_login_ip}</div>
</div>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1">
<button
onClick={() => setPasswordUser(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/30 transition-colors"
title="查看密码"
>
<KeyRound className="w-3.5 h-3.5" />
</button>
<button
onClick={() => showAuditLogs(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-colors"
title="查看操作日志"
>
<ScrollText className="w-3.5 h-3.5" />
</button>
<button
onClick={() => showLoginLogs(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
title="查看登录日志"
>
<LogIn className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{passwordUser && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-lg overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-black dark:text-white"></h3>
<div className="flex items-center gap-2">
<button
onClick={() => rotatePassword(passwordUser)}
disabled={rotatingPassword}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs text-amber-700 bg-amber-50 hover:bg-amber-100 dark:text-amber-300 dark:bg-amber-900/30 dark:hover:bg-amber-900/50 disabled:opacity-50"
title="轮换密码"
>
<RefreshCw className={`w-3.5 h-3.5 ${rotatingPassword ? 'animate-spin' : ''}`} />
{rotatingPassword ? '轮换中...' : '轮换密码'}
</button>
<button onClick={() => setPasswordUser(null)} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-5">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm space-y-3">
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<span className="min-w-0 text-right font-medium text-black dark:text-white break-all">{passwordUser.username}</span>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black dark:text-white break-all">{managementUrl(passwordUser)}</span>
<button onClick={() => copyText(managementUrl(passwordUser))} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black dark:text-white break-all">
{passwordUser.password || '未保存,请轮换生成新密码'}
</span>
{passwordUser.password && (
<button onClick={() => copyText(passwordUser.password || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Log Modal */}
{(auditLogs || loginLogs) && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-black dark:text-white">{modalTitle}</h3>
<button onClick={closeModal} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto flex-1">
{auditLogs && (
<table className="w-full text-sm">
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 sticky top-0">
<tr>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left">IP</th>
<th className="px-4 py-2 text-left">UA</th>
<th className="px-4 py-2 text-center"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{auditLogs.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400"></td></tr>
) : currentAuditLogs?.map((log, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
<td className="px-4 py-2 text-xs text-gray-700 dark:text-gray-300">{log.action}</td>
<td className="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{log.ip || '-'}</td>
<td className="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-[200px] truncate" title={log.user_agent}>{log.user_agent || '-'}</td>
<td className="px-4 py-2 text-center">
{log.success !== undefined ? (
log.success ? (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400"></span>
) : (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400" title={log.error}>{log.error ? '失败' : '失败'}</span>
)
) : (
<span className="text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{loginLogs && (
<table className="w-full text-sm">
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 sticky top-0">
<tr>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left"> IP</th>
<th className="px-4 py-2 text-left">UA</th>
<th className="px-4 py-2 text-center"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{loginLogs.length === 0 ? (
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400"></td></tr>
) : currentLoginLogs?.map((log, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
<td className="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{log.ip}</td>
<td className="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-[250px] truncate" title={log.user_agent}>{log.user_agent}</td>
<td className="px-4 py-2 text-center">
{log.success ? (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400"></span>
) : (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{currentLogTotal > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 dark:border-gray-700 px-5 py-3">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>
{logStart + 1}-{Math.min(logStart + logPageSize, currentLogTotal)} / {currentLogTotal}
</span>
<select
value={logPageSize}
onChange={(event) => {
setLogPageSize(Number(event.target.value))
setLogPage(1)
}}
className="h-7 rounded border border-gray-300 bg-white px-2 text-xs text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300"
>
<option value={10}>10 / </option>
<option value={20}>20 / </option>
<option value={50}>50 / </option>
</select>
</div>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={currentLogPage === 1} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"></button>
<button onClick={() => setLogPage((page) => Math.max(1, page - 1))} disabled={currentLogPage === 1} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"></button>
<span className="px-2 text-xs text-gray-500 dark:text-gray-400">{currentLogPage} / {logTotalPages}</span>
<button onClick={() => setLogPage((page) => Math.min(logTotalPages, page + 1))} disabled={currentLogPage === logTotalPages} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"></button>
<button onClick={() => setLogPage(logTotalPages)} disabled={currentLogPage === logTotalPages} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"></button>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}
+15 -42
View File
@@ -48,6 +48,7 @@ export interface Container {
id: number
uuid: string
name: string
virtualization?: string
template: string
vcpu: number
ram_mb: number
@@ -85,6 +86,7 @@ export interface Container {
export interface Template {
id: string
name: string
type?: string
distro: string
release: string
arch: string
@@ -94,6 +96,7 @@ export interface Template {
export interface CreateContainerRequest {
name: string
virtualization: string
template_id: string
vcpu: number
cpu_percent: number
@@ -197,6 +200,14 @@ export interface LoginLog {
success: boolean
}
export interface AuditLog {
time: string
action: string
target: string
detail: string
user: string
}
export const getLoginLogs = () =>
api.get<APIResponse<LoginLog[]>>('/login-logs')
@@ -330,6 +341,7 @@ export const getTemplates = () =>
export interface ImageInfo {
id: string
name: string
type: string
distro: string
release: string
arch: string
@@ -344,7 +356,7 @@ export const getImages = () =>
api.get<APIResponse<ImageInfo[]>>('/images')
export const downloadImage = (templateId: string) =>
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 600000 }) // 10min timeout
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 1800000 }) // 30min timeout
export const deleteImage = (templateId: string) =>
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
@@ -352,8 +364,8 @@ export const deleteImage = (templateId: string) =>
export const toggleImage = (templateId: string, enabled: boolean) =>
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
export const getEnabledImages = () =>
api.get<APIResponse<Template[]>>('/images/enabled')
export const getEnabledImages = (virtualization = 'lxc') =>
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } })
// Dashboard
export const getDashboard = () =>
@@ -362,45 +374,6 @@ export const getDashboard = () =>
export const getHostInfo = () =>
api.get<APIResponse<HostInfo>>('/host-info')
// Oversell
export interface OversellConfig {
cpu_overcommit: number
ram_overcommit: number
disk_overcommit: number
ksm_enabled: boolean
swappiness: number
}
export interface OversellStatus {
ksm_active: boolean
ksm_pages: number
ksm_supported: boolean
swappiness: number
reclaim_supported: boolean
allocated_cpu: number
allocated_ram_mb: number
allocated_disk_gb: number
}
export interface ReclaimResult {
attempted: number
reclaimed: number
unsupported: number
errors: string[]
}
export const getOversell = () =>
api.get<APIResponse<OversellConfig>>('/oversell')
export const updateOversell = (data: OversellConfig) =>
api.post<APIResponse<OversellConfig>>('/oversell', data)
export const getOversellStatus = () =>
api.get<APIResponse<OversellStatus>>('/oversell/status')
export const reclaimMemory = () =>
api.post<APIResponse<ReclaimResult>>('/oversell/reclaim')
// Snapshots
export interface Snapshot {
id: string
+44
View File
@@ -0,0 +1,44 @@
export async function copyToClipboard(text: string): Promise<boolean> {
if (!text) return false
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through for non-secure HTTP origins where Clipboard API is blocked.
}
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '0'
textarea.style.left = '0'
textarea.style.width = '1px'
textarea.style.height = '1px'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
const selection = document.getSelection()
const selectedRange = selection?.rangeCount ? selection.getRangeAt(0) : null
document.body.appendChild(textarea)
textarea.focus({ preventScroll: true })
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
let copied = false
try {
copied = document.execCommand('copy')
} finally {
document.body.removeChild(textarea)
if (selection && selectedRange) {
selection.removeAllRanges()
selection.addRange(selectedRange)
}
}
return copied
}