· 修复了一些已知问题

· 增加了局域网DHCP IP分配适配
· 完善了多盘兼容支持 #17
This commit is contained in:
MengMengCode
2026-07-18 19:44:32 +08:00
parent 3dabd93d2f
commit ebba97f1d6
34 changed files with 3309 additions and 417 deletions
+4
View File
@@ -309,6 +309,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if err := validateCreateStoragePool(&cfg); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if err := validateCreateSSHAuth(cfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
+193 -2
View File
@@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
@@ -41,6 +42,9 @@ type ImageInfo struct {
var imageDownloadsMu sync.Mutex
var imageDownloads = map[string]*imageDownloadStatus{}
var lxcImageCacheMu sync.Mutex
var lxcImageDownloadMu sync.Mutex
var lxcImageDownloadActive bool
type imageDownloadStatus struct {
Downloading bool
@@ -136,6 +140,22 @@ func isImageDownloadActive(id string) bool {
return st != nil && st.Downloading
}
func beginLXCImageDownload() bool {
lxcImageDownloadMu.Lock()
defer lxcImageDownloadMu.Unlock()
if lxcImageDownloadActive {
return false
}
lxcImageDownloadActive = true
return true
}
func endLXCImageDownload() {
lxcImageDownloadMu.Lock()
lxcImageDownloadActive = false
lxcImageDownloadMu.Unlock()
}
func lxcImageDownloadTempName(id string) string {
return fmt.Sprintf("clicd-img-dl-%s", id)
}
@@ -307,7 +327,6 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return
}
tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil {
image := kvm.FindImage(req.TemplateID)
@@ -319,6 +338,10 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "KVM is not available on this host"})
return
}
if _, err := config.SelectStoragePoolForContent(config.StorageContentImages, "", 1024*1024*1024); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
ensureImageEnabled(image.ID)
clearImageDownload(image.ID)
@@ -359,6 +382,29 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
return
}
if !beginLXCImageDownload() {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Another LXC image download is active"})
return
}
lxcDownloadHandedOff := false
defer func() {
if !lxcDownloadHandedOff {
endLXCImageDownload()
}
}()
imagePool, err := config.SelectStoragePoolForContent(
config.StorageContentImages,
"",
dirSizeBytes("/var/cache/lxc/download")+1024*1024*1024,
)
if err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if err := ensureLXCImageCachePool(*imagePool); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
// Already downloaded? Just enable if needed.
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
@@ -375,6 +421,7 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
}
go func(tmpl lxc.Template) {
defer endLXCImageDownload()
// Download via lxc-create with a temp container, then destroy it.
tmpName := lxcImageDownloadTempName(tmpl.ID)
args := []string{"-n", tmpName, "-t", "download", "--",
@@ -386,7 +433,7 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
st.Stage = "lxc-create"
})
cmd := exec.CommandContext(ctx, "lxc-create", args...)
output, err := cmd.CombinedOutput()
output, err := runLXCImageDownloadCommand(cmd, tmpl.ID)
// Clean up the temp container unconditionally.
cleanupLXCImageDownloadTemp(tmpl.ID)
@@ -403,10 +450,154 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
ensureImageEnabled(tmpl.ID)
finishImageDownload(tmpl.ID, nil)
}(*tmpl)
lxcDownloadHandedOff = true
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
}
type lxcImageDownloadCommandResult struct {
output []byte
err error
}
func runLXCImageDownloadCommand(cmd *exec.Cmd, templateID string) ([]byte, error) {
startedAt := time.Now()
done := make(chan lxcImageDownloadCommandResult, 1)
go func() {
output, err := cmd.CombinedOutput()
done <- lxcImageDownloadCommandResult{output: output, err: err}
}()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
var lastBytes int64
for {
select {
case result := <-done:
return result.output, result.err
case <-ticker.C:
downloadedBytes := newestLXCRootfsDownloadSize(startedAt)
if downloadedBytes <= 0 || downloadedBytes == lastBytes {
continue
}
lastBytes = downloadedBytes
updateImageDownload(templateID, func(st *imageDownloadStatus) {
st.Stage = "downloading"
st.DownloadedBytes = downloadedBytes
})
}
}
}
func newestLXCRootfsDownloadSize(startedAt time.Time) int64 {
matches, _ := filepath.Glob("/tmp/tmp.*/rootfs.tar.xz")
var newestTime time.Time
var newestSize int64
for _, match := range matches {
info, err := os.Stat(match)
if err != nil || info.IsDir() || info.ModTime().Before(startedAt.Add(-5*time.Second)) {
continue
}
if info.ModTime().After(newestTime) {
newestTime = info.ModTime()
newestSize = info.Size()
}
}
return newestSize
}
func ensureLXCImageCachePool(pool config.StoragePool) error {
lxcImageCacheMu.Lock()
defer lxcImageCacheMu.Unlock()
cachePath := "/var/cache/lxc/download"
targetPath := filepath.Join(pool.Path, "images", "lxc")
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return err
}
if err := os.MkdirAll(targetAbs, 0755); err != nil {
return fmt.Errorf("failed to create LXC image storage: %v", err)
}
info, err := os.Lstat(cachePath)
if os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
return err
}
return os.Symlink(targetAbs, cachePath)
}
if err != nil {
return err
}
sourcePath := cachePath
linked := info.Mode()&os.ModeSymlink != 0
if linked {
sourcePath, err = filepath.EvalSymlinks(cachePath)
if err != nil {
return fmt.Errorf("failed to resolve LXC image cache: %v", err)
}
}
sourceAbs, err := filepath.Abs(sourcePath)
if err != nil {
return err
}
if sourceAbs == targetAbs {
return nil
}
if strings.HasPrefix(targetAbs, sourceAbs+string(os.PathSeparator)) || strings.HasPrefix(sourceAbs, targetAbs+string(os.PathSeparator)) {
return fmt.Errorf("LXC image cache source and target must not be nested")
}
if !info.IsDir() && !linked {
return fmt.Errorf("LXC image cache is not a directory: %s", cachePath)
}
if output, err := exec.Command("cp", "-a", sourceAbs+string(os.PathSeparator)+".", targetAbs+string(os.PathSeparator)).CombinedOutput(); err != nil {
return fmt.Errorf("failed to migrate LXC image cache: %v, output: %s", err, strings.TrimSpace(string(output)))
}
tempLink := fmt.Sprintf("%s.clicd-new-%d", cachePath, time.Now().UnixNano())
if err := os.Symlink(targetAbs, tempLink); err != nil {
return err
}
if linked {
if err := os.Rename(tempLink, cachePath); err != nil {
_ = os.Remove(tempLink)
return fmt.Errorf("failed to switch LXC image cache: %v", err)
}
if isManagedLXCImageCachePath(sourceAbs) {
_ = os.RemoveAll(sourceAbs)
}
return nil
}
backupPath := fmt.Sprintf("%s.clicd-backup-%d", cachePath, time.Now().UnixNano())
if err := os.Rename(cachePath, backupPath); err != nil {
_ = os.Remove(tempLink)
return fmt.Errorf("failed to prepare LXC image cache migration: %v", err)
}
if err := os.Rename(tempLink, cachePath); err != nil {
_ = os.Rename(backupPath, cachePath)
_ = os.Remove(tempLink)
return fmt.Errorf("failed to activate LXC image storage: %v", err)
}
if err := os.RemoveAll(backupPath); err != nil {
return fmt.Errorf("LXC image cache migrated but old cache cleanup failed: %v", err)
}
return nil
}
func isManagedLXCImageCachePath(path string) bool {
path = filepath.Clean(path)
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
if path == filepath.Clean(filepath.Join(pool.Path, "images", "lxc")) {
return true
}
}
return path == filepath.Clean("/var/lib/clicd/images/lxc")
}
// HandleImageCancel cancels an in-progress image download.
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
+3 -3
View File
@@ -147,12 +147,12 @@ func trafficByRuntime(id int) map[string]interface{} {
return lxcManager.GetTrafficInfo(id)
}
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...)
}
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...)
}
func deleteSnapshotByRuntime(snapshotID string) error {
+33
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"time"
@@ -48,6 +49,38 @@ func HandleLanguage(w http.ResponseWriter, r *http.Request) {
}
}
// HandleTaskQueueSettings returns or updates the global task concurrency limit.
func HandleTaskQueueSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: globalQueue.Settings()})
case http.MethodPut, http.MethodPost:
var req struct {
Concurrency int `json:"concurrency"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.Concurrency < 1 || req.Concurrency > config.MaxTaskConcurrency {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "任务并发数必须在 1 到 16 之间"})
return
}
previous := config.AppConfig.TaskConcurrency
config.AppConfig.TaskConcurrency = req.Concurrency
if err := config.SaveConfig(); err != nil {
config.AppConfig.TaskConcurrency = previous
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存任务队列设置失败"})
return
}
globalQueue.SetConcurrency(req.Concurrency)
auditRequest(r, "settings.task_queue", "task_concurrency", fmt.Sprintf("concurrency=%d", req.Concurrency), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "任务队列设置已保存", Data: globalQueue.Settings()})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
// RecordLoginLog adds a login attempt to the log (persisted to config)
func RecordLoginLog(username, ip, userAgent string, success bool) {
config.AddLoginLog(username, ip, userAgent, success)
+22 -1
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"io"
"net/http"
"sort"
"strconv"
@@ -88,6 +89,20 @@ func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
user := requestUser(r)
var req struct {
StoragePoolID string `json:"storage_pool_id"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
}
req.StoragePoolID = strings.TrimSpace(req.StoragePoolID)
if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, req.StoragePoolID, 0); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if isSubUserRequest(r) {
c := config.FindContainer(containerID)
limit := config.ContainerSnapshotLimit(c)
@@ -96,7 +111,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
return
}
}
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0, req.StoragePoolID)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
@@ -159,6 +174,12 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
if req.Time == "" {
req.Time = "03:00"
}
if req.Enabled {
if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, "", 0); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
}
user := requestUser(r)
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
if err != nil {
+445
View File
@@ -0,0 +1,445 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
pathpkg "path"
"path/filepath"
"strings"
"clicd/internal/config"
)
type storageInfoResponse struct {
Pools []storagePoolInfo `json:"pools"`
Disks []storageDiskInfo `json:"disks"`
ContentTypes []string `json:"content_types"`
}
type storagePoolInfo struct {
config.StoragePool
Available bool `json:"available"`
Exists bool `json:"exists"`
SizeBytes int64 `json:"size_bytes"`
UsedBytes int64 `json:"used_bytes"`
FreeBytes int64 `json:"free_bytes"`
ClicdUsedBytes int64 `json:"clicd_used_bytes"`
ContentUsage []storageContentUsage `json:"content_usage"`
Error string `json:"error,omitempty"`
}
type storageContentUsage struct {
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
}
type storageDiskInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
FSType string `json:"fstype"`
MountPoint string `json:"mount_point"`
Model string `json:"model"`
SizeBytes int64 `json:"size_bytes"`
UsedBytes int64 `json:"used_bytes"`
FreeBytes int64 `json:"free_bytes"`
StoragePoolID string `json:"storage_pool_id,omitempty"`
StoragePath string `json:"storage_path,omitempty"`
ClicdUsedBytes int64 `json:"clicd_used_bytes"`
ContentUsage []storageContentUsage `json:"content_usage"`
}
func HandleStorage(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()})
case http.MethodPut:
var req struct {
Pools []config.StoragePool `json:"pools"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
pools, err := normalizeStoragePoolsRequest(req.Pools)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
for _, pool := range pools {
if err := os.MkdirAll(pool.Path, 0755); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: fmt.Sprintf("Failed to create %s: %v", pool.Path, err)})
return
}
}
config.AppConfig.StoragePools = pools
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save storage pools"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func buildStorageInfo() storageInfoResponse {
disks := detectStorageDisks()
pools := make([]storagePoolInfo, 0, len(config.AppConfig.StoragePools))
for _, pool := range config.AppConfig.StoragePools {
info := storagePoolInfo{StoragePool: pool}
if filepath.Clean(pool.MountPoint) == string(os.PathSeparator) {
_ = os.MkdirAll(pool.Path, 0755)
}
if st, err := os.Stat(pool.Path); err == nil && st.IsDir() {
info.Exists = true
} else if err != nil {
info.Error = err.Error()
}
detectedMountPoint := bestMountPointForPath(pool.Path, disks)
if info.MountPoint == "" {
info.MountPoint = detectedMountPoint
}
if detectedMountPoint != "" && filepath.Clean(info.MountPoint) == filepath.Clean(detectedMountPoint) {
info.Available = info.Exists
info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(pool.Path)
info.ContentUsage, info.ClicdUsedBytes = contentUsageForPool(pool.Path)
} else if info.Error == "" {
info.Error = "storage disk is not mounted"
}
pools = append(pools, info)
}
for i := range disks {
for _, pool := range pools {
if pool.MountPoint != disks[i].MountPoint {
continue
}
disks[i].ClicdUsedBytes += pool.ClicdUsedBytes
disks[i].ContentUsage = mergeContentUsage(disks[i].ContentUsage, pool.ContentUsage)
if disks[i].StoragePoolID == "" {
disks[i].StoragePoolID = pool.ID
disks[i].StoragePath = pool.Path
}
}
}
return storageInfoResponse{
Pools: pools,
Disks: disks,
ContentTypes: []string{
config.StorageContentLXC,
config.StorageContentKVM,
config.StorageContentImages,
config.StorageContentSnapshots,
config.StorageContentBackups,
},
}
}
func normalizeStoragePoolsRequest(items []config.StoragePool) ([]config.StoragePool, error) {
if len(items) == 0 {
return nil, fmt.Errorf("at least one mounted storage disk configuration must be retained")
}
result := make([]config.StoragePool, 0, len(items))
seen := map[string]bool{}
defaultSeen := map[string]bool{}
disks := detectStorageDisks()
for _, item := range items {
item.ID = strings.TrimSpace(item.ID)
item.Name = strings.TrimSpace(item.Name)
item.Path = filepath.Clean(strings.TrimSpace(item.Path))
item.MountPoint = filepath.Clean(strings.TrimSpace(item.MountPoint))
if item.MountPoint == "." {
item.MountPoint = ""
}
if item.Name == "" {
return nil, fmt.Errorf("storage pool name is required")
}
if item.ID == "" {
item.ID = storageID(item.Name)
}
if seen[item.ID] {
return nil, fmt.Errorf("duplicate storage pool ID: %s", item.ID)
}
seen[item.ID] = true
if !filepath.IsAbs(item.Path) {
return nil, fmt.Errorf("%s path must be absolute", item.Name)
}
detectedMountPoint := bestMountPointForPath(item.Path, disks)
if detectedMountPoint == "" {
return nil, fmt.Errorf("%s path is not on an available mounted storage disk", item.Name)
}
if item.MountPoint != "" && filepath.Clean(item.MountPoint) != filepath.Clean(detectedMountPoint) {
return nil, fmt.Errorf("%s storage disk mount point has changed; refresh and try again", item.Name)
}
item.MountPoint = detectedMountPoint
item.ContentTypes = normalizeStorageContentTypes(item.ContentTypes)
item.DefaultContents = normalizeStorageContentTypes(item.DefaultContents)
allowed := map[string]bool{}
for _, content := range item.ContentTypes {
allowed[content] = true
}
defaults := make([]string, 0, len(item.DefaultContents))
for _, content := range item.DefaultContents {
if !allowed[content] {
continue
}
if defaultSeen[content] {
return nil, fmt.Errorf("only one default storage disk is allowed for %s", content)
}
defaultSeen[content] = true
defaults = append(defaults, content)
}
item.DefaultContents = defaults
result = append(result, item)
}
return result, nil
}
func normalizeStorageContentTypes(values []string) []string {
valid := map[string]bool{
config.StorageContentLXC: true,
config.StorageContentKVM: true,
config.StorageContentImages: true,
config.StorageContentSnapshots: true,
config.StorageContentBackups: true,
}
seen := map[string]bool{}
result := []string{}
for _, value := range values {
next := strings.ToLower(strings.TrimSpace(value))
if !valid[next] || seen[next] {
continue
}
seen[next] = true
result = append(result, next)
}
return result
}
func storageID(name string) string {
id := strings.ToLower(strings.TrimSpace(name))
id = strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-").Replace(id)
id = strings.Trim(id, "-")
if id == "" {
return "storage"
}
return id
}
func detectStorageDisks() []storageDiskInfo {
type lsblkDevice struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
FSType string `json:"fstype"`
MountPoint string `json:"mountpoint"`
Model string `json:"model"`
Size int64 `json:"size"`
ReadOnly bool `json:"ro"`
Children []lsblkDevice `json:"children"`
}
var payload struct {
BlockDevices []lsblkDevice `json:"blockdevices"`
}
out, err := exec.Command("lsblk", "-J", "-b", "-o", "NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,RO").Output()
if err != nil {
return nil
}
if err := json.Unmarshal(out, &payload); err != nil {
return nil
}
result := []storageDiskInfo{}
var walk func(lsblkDevice)
walk = func(dev lsblkDevice) {
info := storageDiskInfo{
Name: dev.Name,
Path: dev.Path,
Type: dev.Type,
FSType: dev.FSType,
MountPoint: dev.MountPoint,
Model: strings.TrimSpace(dev.Model),
SizeBytes: dev.Size,
}
if isUsableStorageMount(dev.Type, dev.FSType, dev.Path, dev.MountPoint, dev.ReadOnly) && !mountIsReadOnly(dev.MountPoint) {
info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(dev.MountPoint)
result = append(result, info)
}
for _, child := range dev.Children {
walk(child)
}
}
for _, dev := range payload.BlockDevices {
walk(dev)
}
return result
}
func isUsableStorageMount(deviceType, fsType, devicePath, mountPoint string, readOnly bool) bool {
if readOnly || strings.TrimSpace(mountPoint) == "" || !strings.HasPrefix(mountPoint, "/") {
return false
}
deviceType = strings.ToLower(strings.TrimSpace(deviceType))
devicePath = strings.ToLower(strings.TrimSpace(devicePath))
if deviceType == "loop" || deviceType == "rom" || deviceType == "zram" || strings.HasPrefix(devicePath, "/dev/loop") {
return false
}
fsType = strings.ToLower(strings.TrimSpace(fsType))
unsupportedFileSystems := map[string]bool{
"": true,
"squashfs": true,
"iso9660": true,
"udf": true,
"swap": true,
"tmpfs": true,
"devtmpfs": true,
"overlay": true,
"proc": true,
"sysfs": true,
"cgroup": true,
"cgroup2": true,
"efivarfs": true,
"securityfs": true,
}
if unsupportedFileSystems[fsType] {
return false
}
mountPoint = pathpkg.Clean(mountPoint)
for _, reserved := range []string{"/snap", "/boot"} {
if mountPoint == reserved || strings.HasPrefix(mountPoint, reserved+"/") {
return false
}
}
return true
}
func mountIsReadOnly(mountPoint string) bool {
out, err := exec.Command("findmnt", "-n", "-o", "OPTIONS", "--target", mountPoint).Output()
if err != nil {
return false
}
for _, option := range strings.Split(strings.TrimSpace(string(out)), ",") {
if strings.TrimSpace(option) == "ro" {
return true
}
}
return false
}
func contentUsageForPool(poolPath string) ([]storageContentUsage, int64) {
mapping := map[string]string{
config.StorageContentLXC: "lxc",
config.StorageContentKVM: "kvm",
config.StorageContentImages: "images",
config.StorageContentSnapshots: "snapshots",
config.StorageContentBackups: "backups",
}
result := make([]storageContentUsage, 0, len(mapping))
var total int64
for _, content := range []string{
config.StorageContentLXC,
config.StorageContentKVM,
config.StorageContentImages,
config.StorageContentSnapshots,
config.StorageContentBackups,
} {
size := dirSizeBytes(filepath.Join(poolPath, mapping[content]))
result = append(result, storageContentUsage{ContentType: content, SizeBytes: size})
total += size
}
return result, total
}
func mergeContentUsage(current []storageContentUsage, next []storageContentUsage) []storageContentUsage {
sizes := map[string]int64{}
order := []string{}
for _, item := range append(current, next...) {
if _, ok := sizes[item.ContentType]; !ok {
order = append(order, item.ContentType)
}
sizes[item.ContentType] += item.SizeBytes
}
result := make([]storageContentUsage, 0, len(order))
for _, content := range order {
result = append(result, storageContentUsage{ContentType: content, SizeBytes: sizes[content]})
}
return result
}
func dirSizeBytes(path string) int64 {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
// Count allocated blocks on this filesystem only. LXC rootfs directories can
// contain active mounts such as proc/sys; traversing them is slow and reports
// enormous virtual sizes that are not actually occupied by CLICD data.
out, err := exec.Command("du", "-skx", path).Output()
if err == nil {
fields := strings.Fields(string(out))
if len(fields) > 0 {
var sizeKB int64
if _, scanErr := fmt.Sscanf(fields[0], "%d", &sizeKB); scanErr == nil && sizeKB <= (1<<63-1)/1024 {
return sizeKB * 1024
}
}
}
var size int64
_ = filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if info, statErr := d.Info(); statErr == nil {
size += info.Size()
}
return nil
})
return size
}
func dfPath(path string) (size int64, used int64, free int64) {
out, err := exec.Command("df", "-B1", "-P", path).Output()
if err != nil {
return 0, 0, 0
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return 0, 0, 0
}
fields := strings.Fields(lines[len(lines)-1])
if len(fields) < 6 {
return 0, 0, 0
}
fmt.Sscanf(fields[1], "%d", &size)
fmt.Sscanf(fields[2], "%d", &used)
fmt.Sscanf(fields[3], "%d", &free)
return size, used, free
}
func bestMountPointForPath(path string, disks []storageDiskInfo) string {
path = strings.ReplaceAll(path, "\\", "/")
path = pathpkg.Clean(path)
best := ""
for _, disk := range disks {
mp := pathpkg.Clean(strings.ReplaceAll(disk.MountPoint, "\\", "/"))
if disk.MountPoint == "" || mp == "." {
continue
}
matches := path == mp
if mp == "/" {
matches = pathpkg.IsAbs(path)
} else if strings.HasPrefix(path, mp+"/") {
matches = true
}
if matches {
if len(mp) > len(best) {
best = mp
}
}
}
return best
}
+79
View File
@@ -0,0 +1,79 @@
package api
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestIsUsableStorageMount(t *testing.T) {
tests := []struct {
name string
deviceType string
fsType string
devicePath string
mountPoint string
readOnly bool
wantUsable bool
}{
{name: "root partition", deviceType: "part", fsType: "ext4", devicePath: "/dev/sda2", mountPoint: "/", wantUsable: true},
{name: "mounted data disk", deviceType: "disk", fsType: "xfs", devicePath: "/dev/sdb", mountPoint: "/data", wantUsable: true},
{name: "snap loop", deviceType: "loop", fsType: "squashfs", devicePath: "/dev/loop0", mountPoint: "/snap/core20/2105", readOnly: true},
{name: "loop without ro flag", deviceType: "loop", fsType: "ext4", devicePath: "/dev/loop7", mountPoint: "/mnt/loop"},
{name: "read only disk", deviceType: "part", fsType: "ext4", devicePath: "/dev/sdc1", mountPoint: "/archive", readOnly: true},
{name: "optical image", deviceType: "rom", fsType: "iso9660", devicePath: "/dev/sr0", mountPoint: "/media/cdrom"},
{name: "efi partition", deviceType: "part", fsType: "vfat", devicePath: "/dev/sda1", mountPoint: "/boot/efi"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isUsableStorageMount(tt.deviceType, tt.fsType, tt.devicePath, tt.mountPoint, tt.readOnly)
if got != tt.wantUsable {
t.Fatalf("isUsableStorageMount() = %v, want %v", got, tt.wantUsable)
}
})
}
}
func TestBestMountPointForPath(t *testing.T) {
disks := []storageDiskInfo{
{Path: "/dev/sda2", MountPoint: "/"},
{Path: "/dev/sdb1", MountPoint: "/mnt/clicd-data"},
}
tests := []struct {
path string
want string
}{
{path: "/var/lib/clicd", want: "/"},
{path: "/mnt/clicd-data/clicd", want: "/mnt/clicd-data"},
{path: "/mnt/clicd-data", want: "/mnt/clicd-data"},
}
for _, tt := range tests {
if got := bestMountPointForPath(tt.path, disks); got != tt.want {
t.Fatalf("bestMountPointForPath(%q) = %q, want %q", tt.path, got, tt.want)
}
}
}
func TestDirSizeBytesUsesAllocatedBlocks(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("allocated-block behavior is provided by the Linux du command")
}
dir := t.TempDir()
file, err := os.Create(filepath.Join(dir, "sparse.img"))
if err != nil {
t.Fatal(err)
}
if err := file.Truncate(1 << 30); err != nil {
file.Close()
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if got := dirSizeBytes(dir); got >= 128<<20 {
t.Fatalf("dirSizeBytes() = %d, expected allocated size instead of 1 GiB apparent size", got)
}
}
+240 -90
View File
@@ -30,6 +30,8 @@ type Task struct {
ContainerName string `json:"container_name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Stage string `json:"stage,omitempty"`
StageDetail string `json:"stage_detail,omitempty"`
CreatedAt string `json:"created_at"`
TemplateID string `json:"template_id,omitempty"`
Config lxc.ContainerConfig `json:"config,omitempty"`
@@ -37,6 +39,7 @@ type Task struct {
User string `json:"user,omitempty"` // who created this task
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
activeKey string
}
type TaskQueue struct {
@@ -47,20 +50,63 @@ type TaskQueue struct {
nextID int
createCond *sync.Cond
opCond *sync.Cond
maxConcurrency int
activeTasks int
activeTargets map[string]bool
stop chan struct{}
}
type TaskQueueSettings struct {
Concurrency int `json:"concurrency"`
Active int `json:"active"`
Pending int `json:"pending"`
}
var globalQueue *TaskQueue
func init() {
globalQueue = &TaskQueue{
globalQueue = newTaskQueue(config.DefaultTaskConcurrency)
go globalQueue.createDispatcher()
go globalQueue.opDispatcher()
}
func newTaskQueue(concurrency int) *TaskQueue {
q := &TaskQueue{
tasks: make(map[string]*Task),
maxConcurrency: config.NormalizeTaskConcurrency(concurrency),
activeTargets: make(map[string]bool),
stop: make(chan struct{}),
}
globalQueue.createCond = sync.NewCond(&globalQueue.mu)
globalQueue.opCond = sync.NewCond(&globalQueue.mu)
go globalQueue.createWorker()
go globalQueue.opWorker()
q.createCond = sync.NewCond(&q.mu)
q.opCond = sync.NewCond(&q.mu)
return q
}
func ConfigureTaskQueue(concurrency int) {
globalQueue.SetConcurrency(concurrency)
}
func (q *TaskQueue) SetConcurrency(concurrency int) {
q.mu.Lock()
q.maxConcurrency = config.NormalizeTaskConcurrency(concurrency)
q.createCond.Broadcast()
q.opCond.Broadcast()
q.mu.Unlock()
}
func (q *TaskQueue) Settings() TaskQueueSettings {
q.mu.Lock()
defer q.mu.Unlock()
return TaskQueueSettings{
Concurrency: q.maxConcurrency,
Active: q.activeTasks,
Pending: len(q.createQueue) + len(q.opQueue),
}
}
func (q *TaskQueue) signalDispatchers() {
q.createCond.Broadcast()
q.opCond.Broadcast()
}
func (q *TaskQueue) enqueueTask(task *Task) {
@@ -90,6 +136,8 @@ func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, task
ContainerID: containerID,
ContainerName: containerName,
Status: "pending",
Stage: "queued",
StageDetail: "排队等待",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID,
User: user,
@@ -172,6 +220,8 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s
ContainerID: 0,
ContainerName: cfgCopy.Name,
Status: "pending",
Stage: "queued",
StageDetail: "排队等待",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
Config: cfgCopy,
User: user,
@@ -202,6 +252,8 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
ContainerID: containerID,
ContainerName: containerName,
Status: "pending",
Stage: "queued",
StageDetail: "排队等待",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID,
User: user,
@@ -262,103 +314,182 @@ func (q *TaskQueue) CancelPendingSecurityStops() int {
return cancelled
}
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
// If a restored task already has a same-name container in config, it resumes
// initialization instead of creating another ct-{id}.
func (q *TaskQueue) createWorker() {
// The two dispatchers keep long-running creates from blocking power operations,
// while sharing one global concurrency budget.
func (q *TaskQueue) createDispatcher() {
for {
q.mu.Lock()
for len(q.createQueue) == 0 {
q.createCond.Wait()
task := q.takeNextTask(true)
go q.runCreateTask(task)
}
task := q.createQueue[0]
q.createQueue = q.createQueue[1:]
task.Status = "running"
q.mu.Unlock()
}
func (q *TaskQueue) opDispatcher() {
for {
task := q.takeNextTask(false)
go q.runOperationTask(task)
}
}
func (q *TaskQueue) takeNextTask(create bool) *Task {
q.mu.Lock()
defer q.mu.Unlock()
cond := q.opCond
if create {
cond = q.createCond
}
for {
queue := q.opQueue
if create {
queue = q.createQueue
}
if q.activeTasks < q.maxConcurrency {
if index := runnableTaskIndex(queue, q.activeTargets); index >= 0 {
task := queue[index]
queue = append(queue[:index], queue[index+1:]...)
if create {
q.createQueue = queue
} else {
q.opQueue = queue
}
task.Status = "running"
task.Error = ""
task.Stage = "preparing"
task.StageDetail = "准备初始化环境"
task.activeKey = taskConcurrencyKey(task)
q.activeTargets[task.activeKey] = true
q.activeTasks++
q.persistTasks()
return task
}
}
cond.Wait()
}
}
func runnableTaskIndex(queue []*Task, activeTargets map[string]bool) int {
for index, task := range queue {
if !activeTargets[taskConcurrencyKey(task)] {
return index
}
}
return -1
}
func taskConcurrencyKey(task *Task) string {
if task == nil {
return "task:nil"
}
name := strings.TrimSpace(task.ContainerName)
if name == "" {
name = strings.TrimSpace(task.Config.Name)
}
if name != "" {
return "name:" + strings.ToLower(name)
}
if task.ContainerID > 0 {
return fmt.Sprintf("id:%d", task.ContainerID)
}
return "task:" + task.ID
}
func (q *TaskQueue) finishTask(task *Task, status string, taskErr error) {
q.mu.Lock()
task.Status = status
if taskErr != nil {
task.Error = taskErr.Error()
if task.Type == TaskCreate {
task.Stage = "failed"
task.StageDetail = "初始化失败"
}
} else {
task.Error = ""
if task.Type == TaskCreate {
task.Stage = "completed"
task.StageDetail = "初始化完成"
}
}
if task.activeKey != "" {
delete(q.activeTargets, task.activeKey)
task.activeKey = ""
}
if q.activeTasks > 0 {
q.activeTasks--
}
q.persistTasks()
q.signalDispatchers()
q.mu.Unlock()
}
func (q *TaskQueue) updateTaskStage(task *Task, stage, detail string) {
q.mu.Lock()
task.Stage = stage
task.StageDetail = detail
q.mu.Unlock()
}
// runCreateTask handles lxc-create, resource setup, start, and SSH init. A
// restored task resumes initialization when the same-name container exists.
func (q *TaskQueue) runCreateTask(task *Task) {
q.mu.Lock()
createdByTask := false
if task.Config.Name == "" {
task.Config.Name = task.ContainerName
}
task.Config.NormalizeResourceAliases()
if task.Config.Name == "" {
task.Status = "failed"
task.Error = "container name is required"
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+task.Error, "admin")
q.mu.Lock()
q.persistTasks()
cfg := task.Config
q.mu.Unlock()
continue
cfg.Progress = func(stage, detail string) {
q.updateTaskStage(task, stage, detail)
}
c := config.FindContainerByName(task.Config.Name)
if cfg.Name == "" {
err := fmt.Errorf("container name is required")
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+err.Error(), "admin")
q.finishTask(task, "failed", err)
return
}
c := config.FindContainerByName(cfg.Name)
if c == nil {
// 1) Download image + apply limits (lxc-create)
err := createByRuntime(task.Config)
if err != nil {
task.Status = "failed"
task.Error = err.Error()
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
continue
if err := createByRuntime(cfg); err != nil {
config.AddAuditLog(string(task.Type), cfg.Name, "失败: "+err.Error(), "admin")
q.finishTask(task, "failed", err)
return
}
createdByTask = true
// 2) Find created container by name
c = config.FindContainerByName(task.Config.Name)
c = config.FindContainerByName(cfg.Name)
if c == nil {
task.Status = "failed"
task.Error = "created but not found in config"
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+task.Error, "admin")
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
continue
err := fmt.Errorf("created but not found in config")
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
q.finishTask(task, "failed", err)
return
}
}
q.mu.Lock()
task.ContainerID = c.ID
task.ContainerName = c.Name
// 3) Start + initialize SSH/network in the same worker.
// If init fails, destroy the container so no dead entry remains.
startErr := startByRuntime(c.ID)
if startErr != nil {
q.mu.Unlock()
startDetail := "启动容器并等待网络就绪"
if strings.EqualFold(cfg.Virtualization, config.VirtualizationKVM) {
startDetail = "启动虚拟机并等待网络就绪"
}
q.updateTaskStage(task, "starting", startDetail)
if err := startByRuntime(c.ID); err != nil {
if createdByTask {
_ = destroyByRuntime(c.ID)
}
task.Status = "failed"
task.Error = startErr.Error()
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+startErr.Error(), "admin")
} else {
task.Status = "done"
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+err.Error(), "admin")
q.finishTask(task, "failed", err)
return
}
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
}
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
}
q.finishTask(task, "done", nil)
}
// opWorker handles all non-create tasks (start, stop, restart, delete, reinstall)
// including the follow-up initialization after a create succeeds.
func (q *TaskQueue) opWorker() {
for {
func (q *TaskQueue) runOperationTask(task *Task) {
q.mu.Lock()
for len(q.opQueue) == 0 {
q.opCond.Wait()
}
task := q.opQueue[0]
q.opQueue = q.opQueue[1:]
task.Status = "running"
err := resolveTaskContainer(task)
q.mu.Unlock()
var err error
skipped := false
err = resolveTaskContainer(task)
// Block operations on expired or traffic-exceeded containers (except stop/delete)
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
c := config.FindContainer(task.ContainerID)
if c != nil {
@@ -372,8 +503,7 @@ func (q *TaskQueue) opWorker() {
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
skipped = true
}
if err == nil {
if !skipped {
if err == nil && !skipped {
switch task.Type {
case TaskStart:
err = startByRuntime(task.ContainerID)
@@ -384,7 +514,7 @@ func (q *TaskQueue) opWorker() {
case TaskDelete:
err = destroyByRuntime(task.ContainerID)
if err == nil {
time.Sleep(1 * time.Second)
time.Sleep(time.Second)
if config.FindContainer(task.ContainerID) != nil {
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
}
@@ -397,22 +527,22 @@ func (q *TaskQueue) opWorker() {
}
}
}
}
q.mu.Lock()
auditUser := task.User
if auditUser == "" {
auditUser = "admin"
}
if err != nil {
task.Status = "failed"
task.Error = err.Error()
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
} else if skipped {
task.Status = "done"
q.finishTask(task, "failed", err)
return
}
if skipped {
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
} else {
task.Status = "done"
q.finishTask(task, "done", nil)
return
}
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
switch task.Type {
case TaskStart:
@@ -426,10 +556,7 @@ func (q *TaskQueue) opWorker() {
case TaskReinstall:
clearPolicyBlockAfterAdminRecovery(task)
}
}
q.persistTasks()
q.mu.Unlock()
}
q.finishTask(task, "done", nil)
}
func isSecurityStopTask(task *Task) bool {
@@ -503,7 +630,8 @@ func (q *TaskQueue) GetTasks() []*Task {
result := make([]*Task, 0, len(q.tasks))
// Collect all task IDs, sort by creation time (extracted from ID number)
for _, t := range q.tasks {
result = append(result, t)
copyTask := *t
result = append(result, &copyTask)
}
// Stable sort by ID number (task-N where N is sequential)
for i := 0; i < len(result); i++ {
@@ -660,6 +788,10 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].DiskGB < 1 {
req.Containers[i].DiskGB = 5
}
if err := validateCreateStoragePool(&req.Containers[i]); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
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
@@ -879,6 +1011,8 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
// RestoreTasks restores task queue from config
func RestoreTasks() {
globalQueue.mu.Lock()
defer globalQueue.mu.Unlock()
for _, st := range config.AppConfig.Tasks {
if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown {
continue
@@ -908,6 +1042,8 @@ func RestoreTasks() {
ContainerName: containerName,
Status: st.Status,
Error: st.Error,
Stage: "queued",
StageDetail: "排队等待",
CreatedAt: st.CreatedAt,
TemplateID: st.TemplateID,
Config: cfg,
@@ -937,3 +1073,17 @@ func parseIDNum(id string) int {
}
return num
}
func validateCreateStoragePool(cfg *lxc.ContainerConfig) error {
required := config.StorageContentLXC
if cfg.Virtualization == config.VirtualizationKVM {
required = config.StorageContentKVM
}
requiredBytes := int64(cfg.DiskGB) * 1024 * 1024 * 1024
pool, err := config.SelectStoragePoolForContent(required, cfg.StoragePoolID, requiredBytes)
if err != nil {
return err
}
cfg.StoragePoolID = pool.ID
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"testing"
"clicd/internal/config"
"clicd/internal/lxc"
)
func TestRunnableTaskIndexSkipsActiveContainer(t *testing.T) {
queue := []*Task{
{ID: "task-1", Type: TaskStop, ContainerID: 1, ContainerName: "alpha"},
{ID: "task-2", Type: TaskStart, ContainerID: 1, ContainerName: "alpha"},
{ID: "task-3", Type: TaskStart, ContainerID: 2, ContainerName: "beta"},
}
active := map[string]bool{taskConcurrencyKey(queue[0]): true}
if got := runnableTaskIndex(queue[1:], active); got != 1 {
t.Fatalf("runnableTaskIndex() = %d, want 1 for the other container", got)
}
}
func TestTaskConcurrencyKeyUsesContainerName(t *testing.T) {
create := &Task{ID: "task-1", Type: TaskCreate, Config: lxcConfigWithName("Example")}
operation := &Task{ID: "task-2", Type: TaskDelete, ContainerID: 9, ContainerName: "example"}
if taskConcurrencyKey(create) != taskConcurrencyKey(operation) {
t.Fatalf("same container received different concurrency keys: %q and %q", taskConcurrencyKey(create), taskConcurrencyKey(operation))
}
}
func TestTaskQueueSetConcurrencyNormalizesAndReports(t *testing.T) {
q := newTaskQueue(config.DefaultTaskConcurrency)
q.SetConcurrency(config.MaxTaskConcurrency + 10)
if got := q.Settings().Concurrency; got != config.MaxTaskConcurrency {
t.Fatalf("concurrency = %d, want %d", got, config.MaxTaskConcurrency)
}
q.SetConcurrency(0)
if got := q.Settings().Concurrency; got != config.DefaultTaskConcurrency {
t.Fatalf("concurrency = %d, want default %d", got, config.DefaultTaskConcurrency)
}
}
func TestTaskQueueUpdateTaskStage(t *testing.T) {
q := newTaskQueue(config.DefaultTaskConcurrency)
task := &Task{ID: "task-1", Type: TaskCreate, Status: "running"}
q.updateTaskStage(task, "rootfs", "下载模板并创建基础文件系统")
if task.Stage != "rootfs" || task.StageDetail != "下载模板并创建基础文件系统" {
t.Fatalf("unexpected task stage: %q %q", task.Stage, task.StageDetail)
}
}
func lxcConfigWithName(name string) lxc.ContainerConfig {
return lxc.ContainerConfig{Name: name}
}
+381
View File
@@ -5,9 +5,12 @@ import (
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
@@ -109,6 +112,8 @@ type Container struct {
LXCName string `json:"lxc_name,omitempty"`
KVMName string `json:"kvm_name,omitempty"`
DiskImage string `json:"disk_image,omitempty"`
StoragePoolID string `json:"storage_pool_id,omitempty"`
StoragePath string `json:"storage_path,omitempty"`
MACAddress string `json:"mac_address,omitempty"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
@@ -202,6 +207,308 @@ func (c *Container) UsesLANIPv4() bool {
return c.UsesLANDHCP() || c.UsesLANStaticIPv4()
}
func normalizeStoragePools() bool {
if AppConfig == nil {
return false
}
changed := false
result := make([]StoragePool, 0, len(AppConfig.StoragePools))
seen := map[string]bool{}
defaultSeen := map[string]bool{}
for _, pool := range AppConfig.StoragePools {
pool.ID = strings.TrimSpace(pool.ID)
pool.Name = strings.TrimSpace(pool.Name)
pool.Path = filepath.Clean(strings.TrimSpace(pool.Path))
pool.MountPoint = filepath.Clean(strings.TrimSpace(pool.MountPoint))
if pool.MountPoint == "." {
pool.MountPoint = ""
}
if pool.ID == "" {
pool.ID = storagePoolIDFromName(pool.Name, pool.Path)
changed = true
}
if pool.Name == "" {
pool.Name = pool.ID
changed = true
}
if pool.Path == "." || !filepath.IsAbs(pool.Path) || seen[pool.ID] {
changed = true
continue
}
seen[pool.ID] = true
pool.ContentTypes = normalizeStorageContentTypes(pool.ContentTypes)
pool.DefaultContents = normalizeStorageContentTypes(pool.DefaultContents)
allowed := map[string]bool{}
for _, content := range pool.ContentTypes {
allowed[content] = true
}
defaults := make([]string, 0, len(pool.DefaultContents))
for _, content := range pool.DefaultContents {
if !allowed[content] || defaultSeen[content] {
changed = true
continue
}
defaultSeen[content] = true
defaults = append(defaults, content)
}
pool.DefaultContents = defaults
if pool.ContentTypes == nil {
pool.ContentTypes = []string{}
}
result = append(result, pool)
}
if len(result) != len(AppConfig.StoragePools) {
changed = true
}
AppConfig.StoragePools = result
return changed
}
func storagePoolIDFromName(name, path string) string {
base := strings.ToLower(strings.TrimSpace(name))
if base == "" {
base = filepath.Base(filepath.Clean(path))
}
replacer := strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-")
base = replacer.Replace(base)
base = strings.Trim(base, "-")
if base == "" {
base = "storage"
}
return base
}
func normalizeStorageContentTypes(values []string) []string {
if len(values) == 0 {
return nil
}
valid := map[string]bool{
StorageContentLXC: true,
StorageContentKVM: true,
StorageContentImages: true,
StorageContentSnapshots: true,
StorageContentBackups: true,
}
seen := map[string]bool{}
result := []string{}
for _, value := range values {
next := strings.ToLower(strings.TrimSpace(value))
if !valid[next] || seen[next] {
continue
}
seen[next] = true
result = append(result, next)
}
return result
}
func StoragePoolsForContent(content string) []StoragePool {
if AppConfig == nil {
return nil
}
content = strings.ToLower(strings.TrimSpace(content))
result := []StoragePool{}
for _, pool := range AppConfig.StoragePools {
if !pool.Enabled || !storagePoolAllows(pool, content) {
continue
}
result = append(result, pool)
}
return result
}
func StoragePoolByID(id string) *StoragePool {
if AppConfig == nil {
return nil
}
id = strings.TrimSpace(id)
for i := range AppConfig.StoragePools {
if AppConfig.StoragePools[i].ID == id {
return &AppConfig.StoragePools[i]
}
}
return nil
}
func StoragePoolAllowsContent(pool StoragePool, content string) bool {
return storagePoolAllows(pool, strings.ToLower(strings.TrimSpace(content)))
}
func StoragePathForContent(content, fallback string) string {
if pool := DefaultStoragePoolForContent(content); pool != nil {
return pool.Path
}
return fallback
}
// PreferredStoragePoolForContent returns the configured default without doing
// filesystem probes. Use SelectStoragePoolForContent for new writes.
func PreferredStoragePoolForContent(content string) *StoragePool {
if AppConfig == nil {
return nil
}
content = strings.ToLower(strings.TrimSpace(content))
for i := range AppConfig.StoragePools {
pool := &AppConfig.StoragePools[i]
if !pool.Enabled || !storagePoolAllows(*pool, content) {
continue
}
for _, item := range pool.DefaultContents {
if item == content {
return pool
}
}
}
for i := range AppConfig.StoragePools {
pool := &AppConfig.StoragePools[i]
if pool.Enabled && storagePoolAllows(*pool, content) {
return pool
}
}
return nil
}
func DefaultStoragePoolForContent(content string) *StoragePool {
pool, _ := SelectStoragePoolForContent(content, "", 0)
return pool
}
const storagePoolFreeReserveBytes int64 = 256 * 1024 * 1024
type storagePoolCandidate struct {
pool *StoragePool
freeBytes int64
isDefault bool
}
// SelectStoragePoolForContent picks a writable mounted pool. The requested or
// configured default pool is preferred while it has enough space; remaining
// pools are tried by available space from largest to smallest.
func SelectStoragePoolForContent(content, requestedPoolID string, requiredBytes int64) (*StoragePool, error) {
if AppConfig == nil {
return nil, fmt.Errorf("storage configuration is not loaded")
}
content = strings.ToLower(strings.TrimSpace(content))
requestedPoolID = strings.TrimSpace(requestedPoolID)
if requiredBytes < 0 {
requiredBytes = 0
}
requiredFree := requiredBytes + storagePoolFreeReserveBytes
candidates := make([]storagePoolCandidate, 0, len(AppConfig.StoragePools))
configured := 0
for i := range AppConfig.StoragePools {
pool := &AppConfig.StoragePools[i]
if !pool.Enabled || !storagePoolAllows(*pool, content) {
continue
}
configured++
freeBytes, available := probeStoragePoolFreeBytes(*pool)
if !available {
continue
}
candidate := storagePoolCandidate{pool: pool, freeBytes: freeBytes}
for _, item := range pool.DefaultContents {
if item == content {
candidate.isDefault = true
break
}
}
candidates = append(candidates, candidate)
}
if configured == 0 {
return nil, fmt.Errorf("no storage disk is enabled for %s", storageContentLabel(content))
}
if len(candidates) == 0 {
return nil, fmt.Errorf("all storage disks enabled for %s are unavailable or unmounted", storageContentLabel(content))
}
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].freeBytes > candidates[j].freeBytes
})
preferred := func(match func(storagePoolCandidate) bool) *StoragePool {
for _, candidate := range candidates {
if match(candidate) && candidate.freeBytes >= requiredFree {
return candidate.pool
}
}
return nil
}
if requestedPoolID != "" {
if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.pool.ID == requestedPoolID }); pool != nil {
return pool, nil
}
}
if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.isDefault }); pool != nil {
return pool, nil
}
if pool := preferred(func(storagePoolCandidate) bool { return true }); pool != nil {
return pool, nil
}
return nil, fmt.Errorf("storage disks enabled for %s do not have enough free space", storageContentLabel(content))
}
var probeStoragePoolFreeBytes = storagePoolFreeBytes
func storagePoolFreeBytes(pool StoragePool) (int64, bool) {
if strings.TrimSpace(pool.Path) == "" {
return 0, false
}
if _, err := os.Stat(pool.Path); err != nil {
if !os.IsNotExist(err) || filepath.Clean(pool.MountPoint) != string(os.PathSeparator) {
return 0, false
}
if err := os.MkdirAll(pool.Path, 0755); err != nil {
return 0, false
}
}
if mountPoint := strings.TrimSpace(pool.MountPoint); mountPoint != "" {
out, err := exec.Command("findmnt", "-n", "-o", "TARGET", "--target", pool.Path).Output()
if err != nil || filepath.Clean(strings.TrimSpace(string(out))) != filepath.Clean(mountPoint) {
return 0, false
}
}
out, err := exec.Command("df", "-B1", "-P", pool.Path).Output()
if err != nil {
return 0, false
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return 0, false
}
fields := strings.Fields(lines[len(lines)-1])
if len(fields) < 4 {
return 0, false
}
freeBytes, err := strconv.ParseInt(fields[3], 10, 64)
return freeBytes, err == nil
}
func storageContentLabel(content string) string {
switch content {
case StorageContentLXC:
return "LXC containers"
case StorageContentKVM:
return "KVM disks"
case StorageContentImages:
return "image cache"
case StorageContentSnapshots:
return "snapshots"
case StorageContentBackups:
return "backups"
default:
return content
}
}
func storagePoolAllows(pool StoragePool, content string) bool {
for _, item := range pool.ContentTypes {
if item == content {
return true
}
}
return false
}
func (c *Container) NormalizeNetworkAssignments() bool {
changed := false
lanMode := strings.ToLower(strings.TrimSpace(c.LANIPv4Mode))
@@ -422,6 +729,43 @@ type SSLConfig struct {
LastError string `json:"last_error,omitempty"`
}
const (
StorageContentLXC = "lxc"
StorageContentKVM = "kvm"
StorageContentImages = "images"
StorageContentSnapshots = "snapshots"
StorageContentBackups = "backups"
)
type StoragePool struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
MountPoint string `json:"mount_point,omitempty"`
ContentTypes []string `json:"content_types"`
DefaultContents []string `json:"default_contents,omitempty"`
Enabled bool `json:"enabled"`
}
func defaultPrimaryStoragePool() StoragePool {
contents := []string{
StorageContentLXC,
StorageContentKVM,
StorageContentImages,
StorageContentSnapshots,
StorageContentBackups,
}
return StoragePool{
ID: "disk-root",
Name: "system (/)",
Path: "/var/lib/clicd",
MountPoint: "/",
ContentTypes: append([]string(nil), contents...),
DefaultContents: append([]string(nil), contents...),
Enabled: true,
}
}
// ClicdConfig is the main configuration structure
type ClicdConfig struct {
AdminUser string `json:"admin_user"`
@@ -447,16 +791,24 @@ type ClicdConfig struct {
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
TaskConcurrency int `json:"task_concurrency"`
Language string `json:"language"`
SSL SSLConfig `json:"ssl"`
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
StoragePools []StoragePool `json:"storage_pools"`
}
var configPath string
var AppConfig *ClicdConfig
var allocationMu sync.Mutex
const DefaultSnapshotLimit = 3
const (
DefaultTaskConcurrency = 2
MaxTaskConcurrency = 16
)
const (
DefaultNATPortStart = 20000
DefaultNATPortEnd = 65535
@@ -588,6 +940,8 @@ func InitConfig() (*ClicdConfig, error) {
PublicIPv4Pool: []PublicIPv4Assignment{},
PublicIPv6Prefixes: []PublicIPv6Prefix{},
WebSSHAllowedOrigins: []string{},
TaskConcurrency: DefaultTaskConcurrency,
StoragePools: []StoragePool{defaultPrimaryStoragePool()},
}
if err := SaveConfig(); err != nil {
@@ -629,6 +983,10 @@ func normalizeConfigDefaults(dataDir string) bool {
AppConfig.NextContainerID = 1
changed = true
}
if normalized := NormalizeTaskConcurrency(AppConfig.TaskConcurrency); AppConfig.TaskConcurrency != normalized {
AppConfig.TaskConcurrency = normalized
changed = true
}
if AppConfig.DataDir == "" {
AppConfig.DataDir = dataDir
changed = true
@@ -656,6 +1014,13 @@ func normalizeConfigDefaults(dataDir string) bool {
AppConfig.WebSSHAllowedOrigins = normalized
changed = true
}
if len(AppConfig.StoragePools) == 0 {
AppConfig.StoragePools = []StoragePool{defaultPrimaryStoragePool()}
changed = true
}
if normalizeStoragePools() {
changed = true
}
if AppConfig.SubUsers == nil {
AppConfig.SubUsers = make([]SubUser, 0)
changed = true
@@ -701,6 +1066,16 @@ func normalizeConfigDefaults(dataDir string) bool {
return changed
}
func NormalizeTaskConcurrency(value int) int {
if value <= 0 {
return DefaultTaskConcurrency
}
if value > MaxTaskConcurrency {
return MaxTaskConcurrency
}
return value
}
func NormalizeLanguage(language string) string {
switch strings.ToLower(strings.TrimSpace(language)) {
case "en", "en-us", "en_us", "english":
@@ -1047,6 +1422,8 @@ func SaveConfig() error {
// AddContainer adds a container to the config
func AddContainer(c Container) {
allocationMu.Lock()
defer allocationMu.Unlock()
if c.UUID == "" {
c.UUID = NewContainerUUID()
}
@@ -1058,6 +1435,8 @@ func AddContainer(c Container) {
// AllocateContainerID allocates a new container ID
func AllocateContainerID() int {
allocationMu.Lock()
defer allocationMu.Unlock()
id := AppConfig.NextContainerID
AppConfig.NextContainerID++
SaveConfig()
@@ -1334,6 +1713,8 @@ func normalizeNATPortRangeDefaults() bool {
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
func AllocateSSHPort() (int, error) {
allocationMu.Lock()
defer allocationMu.Unlock()
used := collectAllHostPorts()
start, end := NATPortRange()
port := AppConfig.NextSSHPort
+90
View File
@@ -0,0 +1,90 @@
package config
import "testing"
func TestSelectStoragePoolForContent(t *testing.T) {
previousConfig := AppConfig
previousProbe := probeStoragePoolFreeBytes
t.Cleanup(func() {
AppConfig = previousConfig
probeStoragePoolFreeBytes = previousProbe
})
AppConfig = &ClicdConfig{StoragePools: []StoragePool{
{
ID: "primary",
Path: "/primary",
ContentTypes: []string{StorageContentLXC},
DefaultContents: []string{StorageContentLXC},
Enabled: true,
},
{
ID: "large",
Path: "/large",
ContentTypes: []string{StorageContentLXC},
Enabled: true,
},
{
ID: "small",
Path: "/small",
ContentTypes: []string{StorageContentLXC},
Enabled: true,
},
}}
free := map[string]int64{
"primary": 20 * 1024 * 1024 * 1024,
"large": 50 * 1024 * 1024 * 1024,
"small": 10 * 1024 * 1024 * 1024,
}
probeStoragePoolFreeBytes = func(pool StoragePool) (int64, bool) {
value, ok := free[pool.ID]
return value, ok
}
pool, err := SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024)
if err != nil {
t.Fatal(err)
}
if pool.ID != "primary" {
t.Fatalf("selected %q, want configured default primary", pool.ID)
}
free["primary"] = 128 * 1024 * 1024
pool, err = SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024)
if err != nil {
t.Fatal(err)
}
if pool.ID != "large" {
t.Fatalf("selected %q, want largest fallback pool", pool.ID)
}
pool, err = SelectStoragePoolForContent(StorageContentLXC, "small", 5*1024*1024*1024)
if err != nil {
t.Fatal(err)
}
if pool.ID != "small" {
t.Fatalf("selected %q, want requested pool", pool.ID)
}
}
func TestSelectStoragePoolRequiresEnabledContent(t *testing.T) {
previousConfig := AppConfig
previousProbe := probeStoragePoolFreeBytes
t.Cleanup(func() {
AppConfig = previousConfig
probeStoragePoolFreeBytes = previousProbe
})
AppConfig = &ClicdConfig{StoragePools: []StoragePool{{
ID: "primary",
Path: "/primary",
ContentTypes: []string{StorageContentLXC},
Enabled: true,
}}}
probeStoragePoolFreeBytes = func(StoragePool) (int64, bool) { return 100 * 1024 * 1024 * 1024, true }
if _, err := SelectStoragePoolForContent(StorageContentSnapshots, "", 0); err == nil {
t.Fatal("expected snapshots selection to fail when no pool enables snapshots")
}
}
+25 -5
View File
@@ -23,6 +23,7 @@ type savedTaskConfig struct {
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
StoragePoolID string `json:"storage_pool_id,omitempty"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
RAMMB int `json:"ram_mb"`
@@ -190,6 +191,8 @@ func ensureSchema() error {
lxc_name TEXT,
kvm_name TEXT,
disk_image TEXT,
storage_pool_id TEXT,
storage_path TEXT,
mac_address TEXT,
template TEXT,
vcpu REAL,
@@ -461,6 +464,8 @@ func ensureSchemaMigrations() error {
{"containers", "allowed_image_ids", "TEXT"},
{"containers", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "restore_on_host_boot", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "storage_pool_id", "TEXT"},
{"containers", "storage_path", "TEXT"},
{"containers", "lan_ipv4_mode", "TEXT"},
{"containers", "lan_interface", "TEXT"},
{"containers", "lan_ipv4_address", "TEXT NOT NULL DEFAULT ''"},
@@ -515,6 +520,11 @@ func ensureSchemaMigrations() error {
lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil {
return err
}
if _, err := db.Exec(`UPDATE containers
SET storage_pool_id = COALESCE(storage_pool_id, ''),
storage_path = COALESCE(storage_path, '')`); err != nil {
return err
}
if _, err := db.Exec(`UPDATE tasks
SET cfg_lan_ipv4_mode = COALESCE(cfg_lan_ipv4_mode, ''),
cfg_lan_interface = COALESCE(cfg_lan_interface, ''),
@@ -585,6 +595,7 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
NATPortEnd: atoi(meta["nat_port_end"]),
SetupComplete: atob(meta["setup_complete"]),
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
TaskConcurrency: atoi(meta["task_concurrency"]),
Language: meta["language"],
}
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
@@ -602,6 +613,9 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
}
if raw := strings.TrimSpace(meta["storage_pools"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.StoragePools)
}
if cfg.Containers, err = loadContainers(); err != nil {
return nil, false, err
@@ -701,6 +715,7 @@ func saveMeta(tx *sql.Tx) error {
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
storagePoolsJSON, _ := json.Marshal(AppConfig.StoragePools)
values := map[string]string{
"admin_user": AppConfig.AdminUser,
"admin_pass_hash": AppConfig.AdminPassHash,
@@ -714,12 +729,14 @@ func saveMeta(tx *sql.Tx) error {
"nat_port_end": strconv.Itoa(AppConfig.NATPortEnd),
"setup_complete": btoa(AppConfig.SetupComplete),
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
"task_concurrency": strconv.Itoa(AppConfig.TaskConcurrency),
"language": NormalizeLanguage(AppConfig.Language),
"ssl": string(sslJSON),
"ssl_certificates": string(sslCertificatesJSON),
"public_ipv4_pool": string(publicIPv4PoolJSON),
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
"storage_pools": string(storagePoolsJSON),
"schema_version": "1",
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
}
@@ -736,7 +753,7 @@ func saveContainers(tx *sql.Tx) error {
NormalizeContainerResourceAliases(&c)
allowedImageIDs := encodeStringSlice(c.AllowedImageIDs)
if _, err := tx.Exec(`INSERT INTO containers (
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, storage_pool_id, storage_path, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
@@ -748,8 +765,8 @@ func saveContainers(tx *sql.Tx) error {
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
policy_blocked, policy_blocked_reason, policy_blocked_at,
firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.StoragePoolID, c.StoragePath, c.MACAddress, c.Template,
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate,
@@ -957,7 +974,7 @@ func saveSnapshots(tx *sql.Tx) error {
func loadContainers() ([]Container, error) {
rows, err := db.Query(`SELECT
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, storage_pool_id, storage_path, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
@@ -981,11 +998,12 @@ func loadContainers() ([]Container, error) {
var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured, restoreOnHostBoot int
var firewallDefaultAction string
var firewallRulesJSON, allowedImageIDs sql.NullString
var storagePoolID, storagePath sql.NullString
var lanIPv4Mode, lanInterface sql.NullString
var lanIPv4Address, lanIPv4Gateway sql.NullString
var lanIPv4PrefixLen sql.NullInt64
if err := rows.Scan(
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &storagePoolID, &storagePath, &c.MACAddress, &c.Template,
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps,
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
@@ -1000,6 +1018,8 @@ func loadContainers() ([]Container, error) {
); err != nil {
return nil, err
}
c.StoragePoolID = storagePoolID.String
c.StoragePath = storagePath.String
c.LANIPv4Mode = lanIPv4Mode.String
c.LANInterface = lanInterface.String
c.LANIPv4Address = lanIPv4Address.String
@@ -93,11 +93,15 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
if len(cfg.Tasks) != 1 || !strings.Contains(cfg.Tasks[0].Config, `"extra_ports":[80,443]`) {
t.Fatalf("task config was not restored from sqlite columns: %+v", cfg.Tasks)
}
if cfg.TaskConcurrency != DefaultTaskConcurrency {
t.Fatalf("legacy task concurrency = %d, want default %d", cfg.TaskConcurrency, DefaultTaskConcurrency)
}
if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil {
t.Fatalf("sqlite database was not created: %v", err)
}
cfg.Containers[0].Status = "stopped"
cfg.TaskConcurrency = 6
if err := SaveConfig(); err != nil {
t.Fatal(err)
}
@@ -111,6 +115,9 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
if got := cfg.Containers[0].Status; got != "stopped" {
t.Fatalf("expected sqlite value to win after migration, got %q", got)
}
if got := cfg.TaskConcurrency; got != 6 {
t.Fatalf("persisted task concurrency = %d, want 6", got)
}
}
func resetConfigStoreForTest(t *testing.T) {
+113 -12
View File
@@ -95,6 +95,9 @@ var (
)
func BaseDir() string {
if pool := config.PreferredStoragePoolForContent(config.StorageContentKVM); pool != nil {
return filepath.Join(pool.Path, "kvm")
}
return "/var/lib/clicd/kvm"
}
@@ -102,11 +105,30 @@ func NewManager() *Manager {
return &Manager{BasePath: BaseDir()}
}
func NewManagerForStoragePool(poolID string) *Manager {
if pool := config.StoragePoolByID(poolID); pool != nil && pool.Enabled {
for _, content := range pool.ContentTypes {
if content == config.StorageContentKVM {
return &Manager{BasePath: filepath.Join(pool.Path, "kvm")}
}
}
}
return NewManager()
}
func (m *Manager) instancesDir() string {
return filepath.Join(m.BasePath, "instances")
}
func (m *Manager) instanceDir(name string) string {
if config.AppConfig != nil {
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.IsKVM() && c.VirshName() == name && strings.TrimSpace(c.DiskImage) != "" {
return filepath.Dir(c.DiskImage)
}
}
}
return filepath.Join(m.instancesDir(), name)
}
@@ -135,10 +157,19 @@ func DownloadImage(image Image) error {
}
func DownloadImageWithProgress(ctx context.Context, image Image, progress DownloadProgressFunc) error {
if err := os.MkdirAll(CacheDir(), 0755); err != nil {
pool, err := config.SelectStoragePoolForContent(config.StorageContentImages, "", 1024*1024*1024)
if err != nil {
return err
}
target := ImagePath(image.ID)
cacheDir := filepath.Join(pool.Path, "images", "kvm")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return err
}
ext := ".qcow2"
if image.Distro == "windows" {
ext = ".iso"
}
target := filepath.Join(cacheDir, image.ID+ext)
if ok, _ := ImageDownloadedInfo(image.ID); ok {
return nil
}
@@ -348,6 +379,7 @@ func normalizeQCOW2(ctx context.Context, src, target string) error {
func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
cfg.NormalizeResourceAliases()
cfg.ReportProgress("preparing", "检查 KVM 镜像与创建参数")
image := FindImage(cfg.TemplateID)
if image == nil {
return fmt.Errorf("KVM image not found: %s", cfg.TemplateID)
@@ -367,6 +399,22 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) {
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
}
if IsWindowsImage(image.ID) && cfg.DiskGB < 30 {
cfg.DiskGB = 30
} else if image.Desktop != "" && cfg.DiskGB < 20 {
cfg.DiskGB = 20
}
cfg.ReportProgress("storage", "选择虚拟机存储磁盘")
pool, err := config.SelectStoragePoolForContent(
config.StorageContentKVM,
cfg.StoragePoolID,
int64(cfg.DiskGB)*1024*1024*1024,
)
if err != nil {
return err
}
cfg.StoragePoolID = pool.ID
m = NewManagerForStoragePool(pool.ID)
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
} else if !cfg.WantsNAT() {
@@ -424,6 +472,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
sshPublicKey = sshAccess.PublicKey
sshAuthMode = sshAccess.Mode
}
cfg.ReportProgress("addresses", "分配 IPv4 与 IPv6 地址")
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
if err != nil {
return nil, err
@@ -451,6 +500,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if cfg.DiskGB < 30 {
cfg.DiskGB = 30
}
cfg.ReportProgress("disk", "创建 Windows 虚拟磁盘")
if err := createEmptyDisk(diskPath, cfg.DiskGB); err != nil {
return nil, err
}
@@ -459,6 +509,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
}
winAdminPassword = generateWindowsPassword()
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
cfg.ReportProgress("cloud_init", "生成 Windows 自动应答配置")
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List); err != nil {
return nil, err
}
@@ -472,9 +523,11 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
cfg.DiskGB = 20
}
}
cfg.ReportProgress("disk", "创建 KVM 系统磁盘")
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
return nil, err
}
cfg.ReportProgress("cloud_init", "生成 cloud-init 初始化配置")
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, ipv4List, *image, sshAuthMode); err != nil {
return nil, err
}
@@ -484,6 +537,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
return nil, err
}
cfg.ReportProgress("define", "注册 KVM 虚拟机")
cmd := exec.Command("virsh", "define", xmlPath)
if output, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("virsh define failed: %v, output: %s", err, string(output))
@@ -492,6 +546,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
sshPort := 0
portMappings := []config.PortMapping{}
if allocatePorts && cfg.WantsNAT() {
cfg.ReportProgress("nat", "分配并配置 NAT 端口")
sshPort, err = config.AllocateSSHPort()
if err != nil {
return nil, err
@@ -538,6 +593,12 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if trafficMode == "" {
trafficMode = "total"
}
storagePoolID := cfg.StoragePoolID
if storagePoolID == "" {
if pool := config.DefaultStoragePoolForContent(config.StorageContentKVM); pool != nil {
storagePoolID = pool.ID
}
}
container := &config.Container{
ID: id,
UUID: config.NewContainerUUID(),
@@ -545,6 +606,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
Virtualization: config.VirtualizationKVM,
KVMName: vmName,
DiskImage: diskPath,
StoragePoolID: storagePoolID,
StoragePath: m.instanceDir(vmName),
MACAddress: mac,
Template: cfg.TemplateID,
VCPU: cfg.VCPU,
@@ -580,6 +643,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
ExpiresAt: cfg.ExpiresAt,
}
container.NormalizeNetworkAssignments()
cfg.ReportProgress("metadata", "保存虚拟机配置")
return container, nil
}
@@ -954,7 +1018,7 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error {
return nil
}
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
kvmSnapshotMu.Lock()
defer kvmSnapshotMu.Unlock()
@@ -980,17 +1044,26 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
name := c.VirshName()
instanceDir := m.instanceDir(name)
if err := safePathUnder(instanceDir, m.instancesDir()); err != nil {
if err := safePathUnder(instanceDir, filepath.Dir(instanceDir)); err != nil {
return config.Snapshot{}, err
}
if _, err := os.Stat(instanceDir); err != nil {
return config.Snapshot{}, fmt.Errorf("VM storage not found: %v", err)
}
pool, err := config.SelectStoragePoolForContent(
config.StorageContentSnapshots,
firstString(storagePoolID),
dirSizeBytes(instanceDir),
)
if err != nil {
return config.Snapshot{}, err
}
now := time.Now()
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
snapshotDir := filepath.Join(snapshotBaseDir(), "kvm", strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
baseDir := filepath.Join(pool.Path, "snapshots")
snapshotDir := filepath.Join(baseDir, "kvm", strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, baseDir); err != nil {
return config.Snapshot{}, err
}
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
@@ -1043,7 +1116,7 @@ func (m *Manager) DeleteSnapshot(id string) error {
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
if snapshot.Path != "" {
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
if err := safeSnapshotPath(snapshot.Path); err != nil {
return err
}
if err := os.RemoveAll(snapshot.Path); err != nil {
@@ -1065,7 +1138,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
if snapshot.Path == "" {
return fmt.Errorf("snapshot path is empty")
}
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
if err := safeSnapshotPath(snapshot.Path); err != nil {
return err
}
if _, err := os.Stat(snapshot.Path); err != nil {
@@ -1081,7 +1154,8 @@ func (m *Manager) RestoreSnapshot(id string) error {
}
name := c.VirshName()
instanceDir := m.instanceDir(name)
if err := safePathUnder(instanceDir, m.instancesDir()); err != nil {
instanceParent := filepath.Dir(instanceDir)
if err := safePathUnder(instanceDir, instanceParent); err != nil {
return err
}
@@ -1089,8 +1163,8 @@ func (m *Manager) RestoreSnapshot(id string) error {
if err != nil {
return err
}
backupDir := filepath.Join(m.instancesDir(), fmt.Sprintf(".%s-restore-backup-%d", name, time.Now().UnixNano()))
if err := safePathUnder(backupDir, m.instancesDir()); err != nil {
backupDir := filepath.Join(instanceParent, fmt.Sprintf(".%s-restore-backup-%d", name, time.Now().UnixNano()))
if err := safePathUnder(backupDir, instanceParent); err != nil {
return err
}
if err := os.Rename(instanceDir, backupDir); err != nil && !os.IsNotExist(err) {
@@ -1110,6 +1184,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
return fmt.Errorf("virsh define failed after restore: %v, output: %s", err, string(output))
}
c.DiskImage = filepath.Join(instanceDir, "disk.qcow2")
c.StoragePath = instanceDir
c.Status = "stopped"
c.IP = ""
config.SaveConfig()
@@ -1244,7 +1319,33 @@ func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) tim
}
func snapshotBaseDir() string {
return filepath.Join(config.AppConfig.DataDir, "snapshots")
return snapshotBaseDirForPool("")
}
func snapshotBaseDirForPool(poolID string) string {
if pool, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, poolID, 0); err == nil {
return filepath.Join(pool.Path, "snapshots")
}
return ""
}
func safeSnapshotPath(path string) error {
if err := safePathUnder(path, filepath.Join(config.AppConfig.DataDir, "snapshots")); err == nil {
return nil
}
for _, pool := range config.StoragePoolsForContent(config.StorageContentSnapshots) {
if err := safePathUnder(path, filepath.Join(pool.Path, "snapshots")); err == nil {
return nil
}
}
return fmt.Errorf("unsafe snapshot path: %s", path)
}
func firstString(values []string) string {
if len(values) == 0 {
return ""
}
return strings.TrimSpace(values[0])
}
func copyTree(src string, dst string) error {
+18 -1
View File
@@ -1,8 +1,11 @@
package kvm
import (
"os"
"path/filepath"
"runtime"
"clicd/internal/config"
)
type Image struct {
@@ -180,6 +183,9 @@ func FindImage(id string) *Image {
}
func CacheDir() string {
if pool := config.PreferredStoragePoolForContent(config.StorageContentImages); pool != nil {
return filepath.Join(pool.Path, "images", "kvm")
}
return filepath.Join(BaseDir(), "images")
}
@@ -189,7 +195,18 @@ func ImagePath(id string) string {
if img != nil && img.Distro == "windows" {
ext = ".iso"
}
return filepath.Join(CacheDir(), id+ext)
fileName := id + ext
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
candidate := filepath.Join(pool.Path, "images", "kvm", fileName)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
legacy := filepath.Join("/var/lib/clicd/kvm/images", fileName)
if info, err := os.Stat(legacy); err == nil && !info.IsDir() {
return legacy
}
return filepath.Join(CacheDir(), fileName)
}
// IsWindowsImage returns true if the image distro is "windows".
+149 -6
View File
@@ -18,6 +18,7 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"time"
"clicd/internal/config"
@@ -230,6 +231,7 @@ type ContainerConfig struct {
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
StoragePoolID string `json:"storage_pool_id,omitempty"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
RAMMB int `json:"ram_mb"`
@@ -265,6 +267,14 @@ type ContainerConfig struct {
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
ExpiresAt string `json:"expires_at"`
Progress func(stage, detail string) `json:"-"`
}
// ReportProgress reports a best-effort creation phase to the task queue.
func (cfg ContainerConfig) ReportProgress(stage, detail string) {
if cfg.Progress != nil {
cfg.Progress(stage, detail)
}
}
func (cfg *ContainerConfig) NormalizeResourceAliases() {
@@ -324,6 +334,7 @@ func (cfg ContainerConfig) WantsLANIPv4() bool {
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
func (m *Manager) CreateContainer(cfg ContainerConfig) error {
cfg.NormalizeResourceAliases()
cfg.ReportProgress("preparing", "检查模板与创建参数")
tmpl := FindTemplate(cfg.TemplateID)
if tmpl == nil {
return fmt.Errorf("template not found: %s", cfg.TemplateID)
@@ -369,6 +380,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
fmt.Printf("Creating LXC container: %s (ID=%d, template: %s/%s/%s)\n",
lxcName, id, tmpl.Distro, tmpl.Release, tmpl.Arch)
cfg.ReportProgress("rootfs", "下载模板并创建基础文件系统")
args := []string{"-n", lxcName, "-t", "download", "--",
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
if tmpl.Variant != "" {
@@ -380,10 +392,19 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output))
}
cfg.ReportProgress("storage", "复制容器数据到存储磁盘")
storagePoolID, storagePath, err := m.moveContainerToStoragePool(lxcName, cfg.StoragePoolID)
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
cfg.ReportProgress("disk", "创建容量限制磁盘并复制 rootfs")
if err := m.applyDiskLimit(lxcName, cfg.DiskGB); err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
cfg.ReportProgress("resources", "配置 CPU、内存与网络限制")
if cfg.WantsLANIPv4() {
iface, err := m.applyLANIPv4Config(lxcName, cfg)
if err != nil {
@@ -399,6 +420,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
return err
}
cfg.ReportProgress("addresses", "分配 IPv4、IPv6 与 NAT 端口")
publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
@@ -472,6 +494,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
Name: cfg.Name,
Virtualization: config.VirtualizationLXC,
LXCName: lxcName,
StoragePoolID: storagePoolID,
StoragePath: storagePath,
Template: cfg.TemplateID,
VCPU: cfg.VCPU,
RAMMB: cfg.RAMMB,
@@ -509,16 +533,19 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
ExpiresAt: cfg.ExpiresAt,
}
container.NormalizeNetworkAssignments()
cfg.ReportProgress("metadata", "保存容器配置")
config.AddContainer(container)
// Pre-configure network and SSH in the rootfs before first boot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
cfg.ReportProgress("network", "写入容器网络配置")
m.preconfigureNetwork(rootfsPath, cfg)
if len(ipv6Assignments) > 0 {
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
}
}
cfg.ReportProgress("ssh", "安装并配置 SSH 服务")
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID, sshAccess.Mode); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
}
@@ -530,6 +557,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
}
}
cfg.ReportProgress("permissions", "转换非特权容器文件权限")
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
_ = m.cleanupContainerStorage(lxcName)
config.RemoveContainer(id)
@@ -538,6 +566,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
// Set root password AFTER shiftRootfsForUnprivileged,
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
cfg.ReportProgress("credentials", "设置容器登录凭据")
if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
}
@@ -1128,6 +1157,78 @@ func (m *Manager) applyLoopbackDiskLimit(lxcName string, diskGB int) error {
return nil
}
func (m *Manager) moveContainerToStoragePool(lxcName string, requestedPoolID string) (string, string, error) {
sourceDir := filepath.Join(m.LxcPath, lxcName)
requiredBytes := dirSizeBytes(sourceDir)
pool, err := config.SelectStoragePoolForContent(config.StorageContentLXC, requestedPoolID, requiredBytes)
if err != nil {
return "", "", err
}
targetRoot := filepath.Join(pool.Path, "lxc")
targetDir := filepath.Join(targetRoot, lxcName)
sourceAbs, err := filepath.Abs(sourceDir)
if err != nil {
return "", "", err
}
targetAbs, err := filepath.Abs(targetDir)
if err != nil {
return "", "", err
}
if sourceAbs == targetAbs {
return pool.ID, targetAbs, nil
}
if err := os.MkdirAll(targetRoot, 0755); err != nil {
return "", "", err
}
if _, err := os.Lstat(targetDir); err == nil {
return "", "", fmt.Errorf("target storage directory already exists: %s", targetDir)
}
if err := moveLXCStorageDirectory(sourceDir, targetDir); err != nil {
return "", "", err
}
return pool.ID, targetAbs, nil
}
func moveLXCStorageDirectory(sourceDir, targetDir string) error {
if err := os.Rename(sourceDir, targetDir); err == nil {
if err := os.Symlink(targetDir, sourceDir); err != nil {
_ = os.Rename(targetDir, sourceDir)
return fmt.Errorf("failed to create LXC storage symlink: %v", err)
}
return nil
} else if !errors.Is(err, syscall.EXDEV) {
return fmt.Errorf("failed to move LXC container to storage pool: %v", err)
}
if err := copyTree(sourceDir, targetDir); err != nil {
_ = os.RemoveAll(targetDir)
return fmt.Errorf("failed to copy LXC container to storage pool: %v", err)
}
backupDir := sourceDir + fmt.Sprintf(".storage-move-%d", time.Now().UnixNano())
if err := os.Rename(sourceDir, backupDir); err != nil {
_ = os.RemoveAll(targetDir)
return fmt.Errorf("failed to finalize LXC storage move: %v", err)
}
if err := os.Symlink(targetDir, sourceDir); err != nil {
_ = os.Rename(backupDir, sourceDir)
_ = os.RemoveAll(targetDir)
return fmt.Errorf("failed to create LXC storage symlink: %v", err)
}
if err := os.RemoveAll(backupDir); err != nil {
fmt.Printf("Warning: LXC storage moved but source cleanup failed: %v\n", err)
}
return nil
}
func storagePoolAllowsContent(pool config.StoragePool, content string) bool {
for _, item := range pool.ContentTypes {
if item == content {
return true
}
}
return false
}
func (m *Manager) ensureDiskImageMounted(lxcName string) error {
containerDir := filepath.Join(m.LxcPath, lxcName)
rootfsPath := filepath.Join(containerDir, "rootfs")
@@ -1183,15 +1284,30 @@ func diskImageMounted(lxcName, rootfsPath string) bool {
if err != nil {
return false
}
targetAbs, err := filepath.Abs(strings.TrimSpace(target))
if err != nil {
return false
return sameFilesystemPath(strings.TrimSpace(target), rootfsPath)
}
func sameFilesystemPath(left, right string) bool {
leftInfo, leftErr := os.Stat(left)
rightInfo, rightErr := os.Stat(right)
if leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) {
return true
}
rootfsAbs, err := filepath.Abs(rootfsPath)
canonical := func(path string) (string, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return false
return "", err
}
return targetAbs == rootfsAbs
resolved, err := filepath.EvalSymlinks(absolute)
if err == nil {
absolute = resolved
}
return filepath.Clean(absolute), nil
}
leftPath, leftErr := canonical(left)
rightPath, rightErr := canonical(right)
return leftErr == nil && rightErr == nil && leftPath == rightPath
}
func applyXFSProjectQuota(rootfsPath, lxcName string, diskGB int) error {
@@ -2745,6 +2861,17 @@ func (m *Manager) cleanupContainerStorage(lxcName string) error {
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
return nil
}
var linkedTarget string
if info, err := os.Lstat(cleanPath); err == nil && info.Mode()&os.ModeSymlink != 0 {
if target, err := os.Readlink(cleanPath); err == nil {
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(cleanPath), target)
}
if abs, err := filepath.Abs(target); err == nil && lxcStorageTargetAllowed(abs) {
linkedTarget = abs
}
}
}
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
m.detachContainerMounts(cleanPath)
@@ -2756,9 +2883,25 @@ func (m *Manager) cleanupContainerStorage(lxcName string) error {
if err := os.RemoveAll(cleanPath); err != nil {
return fmt.Errorf("failed to remove container directory %s: %v", cleanPath, err)
}
if linkedTarget != "" {
m.detachContainerMounts(linkedTarget)
m.detachContainerLoopDevices(linkedTarget)
_ = os.RemoveAll(linkedTarget)
}
return nil
}
func lxcStorageTargetAllowed(path string) bool {
for _, pool := range config.StoragePoolsForContent(config.StorageContentLXC) {
root := filepath.Join(pool.Path, "lxc")
rel, err := filepath.Rel(root, path)
if err == nil && rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) {
return true
}
}
return false
}
func (m *Manager) detachContainerMounts(containerDir string) {
out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", containerDir).Output()
if err != nil {
+23
View File
@@ -121,6 +121,29 @@ func TestManagedPrlimitLinesDoNotSetNproc(t *testing.T) {
}
}
func TestSameFilesystemPathResolvesContainerStorageSymlink(t *testing.T) {
base := t.TempDir()
storageContainer := filepath.Join(base, "storage", "ct-1")
rootfs := filepath.Join(storageContainer, "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil {
t.Fatal(err)
}
lxcPath := filepath.Join(base, "lxc")
if err := os.MkdirAll(lxcPath, 0755); err != nil {
t.Fatal(err)
}
containerLink := filepath.Join(lxcPath, "ct-1")
if err := os.Symlink(storageContainer, containerLink); err != nil {
t.Skipf("directory symlinks are unavailable: %v", err)
}
linkedRootfs := filepath.Join(containerLink, "rootfs")
if !sameFilesystemPath(rootfs, linkedRootfs) {
t.Fatalf("sameFilesystemPath(%q, %q) = false, want true", rootfs, linkedRootfs)
}
}
func TestAppendMissingSeccompRulesAddsFutexMitigationOnce(t *testing.T) {
base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n"
+44 -6
View File
@@ -16,7 +16,7 @@ import (
var snapshotMu sync.Mutex
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
snapshotMu.Lock()
defer snapshotMu.Unlock()
@@ -42,12 +42,21 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
if _, err := os.Stat(containerDir); err != nil {
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
}
pool, err := config.SelectStoragePoolForContent(
config.StorageContentSnapshots,
firstString(storagePoolID),
dirSizeBytes(containerDir),
)
if err != nil {
return config.Snapshot{}, err
}
now := time.Now()
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
// 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 {
baseDir := filepath.Join(pool.Path, "snapshots")
snapshotDir := filepath.Join(baseDir, strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, baseDir); err != nil {
return config.Snapshot{}, err
}
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
@@ -100,7 +109,7 @@ func (m *Manager) DeleteSnapshot(id string) error {
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
if snapshot.Path != "" {
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
if err := safeSnapshotPath(snapshot.Path); err != nil {
return err
}
if err := os.RemoveAll(snapshot.Path); err != nil {
@@ -122,7 +131,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
if snapshot.Path == "" {
return fmt.Errorf("snapshot path is empty")
}
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
if err := safeSnapshotPath(snapshot.Path); err != nil {
return err
}
if _, err := os.Stat(snapshot.Path); err != nil {
@@ -295,7 +304,33 @@ func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerD
}
func snapshotBaseDir() string {
return filepath.Join(config.AppConfig.DataDir, "snapshots")
return snapshotBaseDirForPool("")
}
func snapshotBaseDirForPool(poolID string) string {
if pool, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, poolID, 0); err == nil {
return filepath.Join(pool.Path, "snapshots")
}
return ""
}
func safeSnapshotPath(path string) error {
if err := safePathUnder(path, filepath.Join(config.AppConfig.DataDir, "snapshots")); err == nil {
return nil
}
for _, pool := range config.StoragePoolsForContent(config.StorageContentSnapshots) {
if err := safePathUnder(path, filepath.Join(pool.Path, "snapshots")); err == nil {
return nil
}
}
return fmt.Errorf("unsafe snapshot path: %s", path)
}
func firstString(values []string) string {
if len(values) == 0 {
return ""
}
return strings.TrimSpace(values[0])
}
func copyTree(src string, dst string) error {
@@ -313,6 +348,9 @@ func copyTree(src string, dst string) error {
}
func dirSizeBytes(path string) int64 {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
out, err := exec.Command("du", "-s", "-B1", path).Output()
if err != nil {
return 0
+4
View File
@@ -66,9 +66,11 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage)))
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
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/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings)))
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction)))
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
@@ -110,9 +112,11 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/v1/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage)))
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
mux.HandleFunc("/api/v1/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings)))
mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
+1
View File
@@ -52,6 +52,7 @@ func main() {
installShutdownStateCapture()
// Restore persisted state
api.ConfigureTaskQueue(cfg.TaskConcurrency)
api.RestoreTasks()
api.RestoreLoginLogs()
+2
View File
@@ -13,6 +13,7 @@ import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement'
import Snapshots from './pages/Snapshots'
import Routing from './pages/Routing'
import Storage from './pages/Storage'
import SubUserManagement from './pages/SubUserManagement'
import Layout from './components/Layout'
@@ -63,6 +64,7 @@ function App() {
<Route path="security" element={<Security />} />
<Route path="snapshots" element={<Snapshots />} />
<Route path="routing" element={<Routing />} />
<Route path="storage" element={<Storage />} />
<Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} />
<Route path="host-report" element={<HostReport />} />
@@ -1,19 +1,21 @@
import { useEffect } from 'react'
import { useLanguage } from '../contexts/LanguageContext'
import { useDialog } from './Dialog'
export default function BrowserDialogTranslator() {
const { t } = useLanguage()
const { alert: showAlert } = useDialog()
useEffect(() => {
const originalAlert = window.alert
const originalConfirm = window.confirm
window.alert = (message?: unknown) => originalAlert(t(String(message ?? '')))
window.alert = (message?: unknown) => { void showAlert('提示', String(message ?? '')) }
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
return () => {
window.alert = originalAlert
window.confirm = originalConfirm
}
}, [t])
}, [showAlert, t])
return null
}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { CalendarClock, RefreshCw, X } from 'lucide-react'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, Template } from '../services/api'
import { useNavigate } from 'react-router-dom'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, StorageInfo, Template } from '../services/api'
import { useDialog } from './Dialog'
import { useLanguage, type Language } from '../contexts/LanguageContext'
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
@@ -16,6 +17,7 @@ const defaultForm: CreateContainerRequest = {
name: '',
virtualization: 'lxc',
template_id: '',
storage_pool_id: '',
vcpu: 1,
cpu_percent: 100,
ram_mb: 512,
@@ -54,6 +56,7 @@ const defaultForm: CreateContainerRequest = {
}
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
const navigate = useNavigate()
const dialog = useDialog()
const { language } = useLanguage()
const networkText = createNetworkText[language]
@@ -63,6 +66,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [hostReport, setHostReport] = useState<HostProbeReport | null>(null)
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
const [storageLoading, setStorageLoading] = useState(true)
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
const [nameError, setNameError] = useState('')
@@ -107,8 +112,26 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
getHostReport()
.then((res) => setHostReport(res.data.data || null))
.catch(() => setHostReport(null))
}, [isOpen, form.virtualization])
useEffect(() => {
if (!isOpen) return
let active = true
setStorageLoading(true)
getStorageInfo()
.then((res) => {
if (active) setStorageInfo(res.data.data || null)
})
.catch(() => {
if (active) setStorageInfo(null)
})
.finally(() => {
if (active) setStorageLoading(false)
})
return () => { active = false }
}, [isOpen])
const ipv6Available = !!ipv6Status?.available
const ipv6Prefixes = ipv6Status?.prefixes || []
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
@@ -118,6 +141,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const kvmAvailable = !!hostInfo?.runtime?.kvm_available
const storagePools = useMemo(() => {
const content = form.virtualization === 'kvm' ? 'kvm' : 'lxc'
return (storageInfo?.pools || []).filter((pool) => pool.enabled && pool.available !== false && (pool.content_types || []).includes(content))
}, [storageInfo, form.virtualization])
const storageReady = storagePools.length > 0
useEffect(() => {
if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') {
@@ -197,6 +225,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
}
}
if (!storageReady) {
dialog.alert('未配置存储', `请先在存储管理中为 ${form.virtualization === 'kvm' ? 'KVM 磁盘' : 'LXC 容器'}开启至少一块存储磁盘`)
return
}
const authError = validateSSHAuthInputs(form)
if (authError) {
dialog.alert('登录方式有误', authError)
@@ -273,7 +306,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '', allowed_image_ids: [], image_limit_configured: false }))}
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '', storage_pool_id: '', allowed_image_ids: [], image_limit_configured: false }))}
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
@@ -284,7 +317,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
onClick={() => {
if (kvmAvailable) {
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '', allowed_image_ids: [], image_limit_configured: false }))
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '', storage_pool_id: '', allowed_image_ids: [], image_limit_configured: false }))
}
}}
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
@@ -320,6 +353,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</Field>
<Field label="存储磁盘">
{storageLoading ? (
<div className="flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600">
<RefreshCw className="h-4 w-4 animate-spin" />
...
</div>
) : storagePools.length > 0 ? (
<select
value={form.storage_pool_id || ''}
onChange={(event) => setForm({ ...form, storage_pool_id: event.target.value })}
className={inputClass}
>
<option value=""></option>
{storagePools.map((pool) => (
<option key={pool.id} value={pool.id}>
{pool.name} · {pool.mount_point || pool.path}
</option>
))}
</select>
) : (
<div className="flex items-center justify-between gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
<span>{form.virtualization === 'kvm' ? ' KVM 磁盘' : ' LXC 容器'}</span>
<button
type="button"
onClick={() => { onClose(); navigate('/storage') }}
className="shrink-0 rounded-md border border-amber-300 bg-white px-2.5 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-100"
>
</button>
</div>
)}
</Field>
{templates.length > 0 && (
<Field label="子用户可用镜像">
<div className="rounded-md border border-gray-200 bg-gray-50 p-3">
@@ -821,7 +887,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</button>
<button
onClick={handleSubmit}
disabled={loading}
disabled={loading || storageLoading || !storageReady}
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '创建中...' : '创建容器'}
+81 -35
View File
@@ -1,17 +1,23 @@
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'
import { AlertTriangle, CheckCircle2, CircleAlert, Info, X } from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext'
type DialogType = 'confirm' | 'alert'
interface DialogState {
open: boolean
type: DialogType
title: string
message: string
resolve?: (value: boolean) => void
}
type ToastTone = 'success' | 'error' | 'warning' | 'info'
interface ToastState {
id: number
title: string
message: string
tone: ToastTone
}
interface DialogContextType {
confirm: (title: string, message: string) => Promise<boolean>
alert: (title: string, message: string) => Promise<void>
@@ -19,67 +25,107 @@ interface DialogContextType {
const DialogContext = createContext<DialogContextType | undefined>(undefined)
const toastStyles = {
success: { icon: CheckCircle2, iconClass: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-300', borderClass: 'border-emerald-200 dark:border-emerald-800' },
error: { icon: CircleAlert, iconClass: 'bg-red-50 text-red-600 dark:bg-red-950 dark:text-red-300', borderClass: 'border-red-200 dark:border-red-800' },
warning: { icon: AlertTriangle, iconClass: 'bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300', borderClass: 'border-amber-200 dark:border-amber-800' },
info: { icon: Info, iconClass: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300', borderClass: 'border-gray-200 dark:border-gray-700' },
}
function toastTone(title: string): ToastTone {
if (/失败|错误|异常|不可用|failed|error/i.test(title)) return 'error'
if (/提示|警告|未配置|格式|配额|封禁|warning/i.test(title)) return 'warning'
if (/完成|成功|已保存|success/i.test(title)) return 'success'
return 'info'
}
export function DialogProvider({ children }: { children: ReactNode }) {
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
const [dialog, setDialog] = useState<DialogState>({ open: false, title: '', message: '' })
const [toasts, setToasts] = useState<ToastState[]>([])
const toastID = useRef(0)
const toastTimers = useRef(new Map<number, number>())
const { t } = useLanguage()
const confirm = useCallback((title: string, message: string) => {
return new Promise<boolean>((resolve) => {
setDialog({ open: true, type: 'confirm', title, message, resolve })
setDialog({ open: true, title, message, resolve })
})
}, [])
const dismissToast = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id))
const timer = toastTimers.current.get(id)
if (timer !== undefined) window.clearTimeout(timer)
toastTimers.current.delete(id)
}, [])
const alert = useCallback((title: string, message: string) => {
return new Promise<void>((resolve) => {
setDialog({ open: true, type: 'alert', title, message, resolve: () => resolve() })
})
const id = ++toastID.current
setToasts((current) => [...current, { id, title, message, tone: toastTone(title) }].slice(-4))
const timer = window.setTimeout(() => dismissToast(id), 4200)
toastTimers.current.set(id, timer)
return Promise.resolve()
}, [dismissToast])
useEffect(() => () => {
toastTimers.current.forEach((timer) => window.clearTimeout(timer))
toastTimers.current.clear()
}, [])
const close = (result: boolean) => {
dialog.resolve?.(result)
setDialog({ open: false, type: 'alert', title: '', message: '' })
setDialog({ open: false, title: '', message: '' })
}
return (
<DialogContext.Provider value={{ confirm, alert }}>
{children}
{dialog.open && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-lg shadow-xl border border-gray-200 w-full max-w-sm overflow-hidden">
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
dialog.type === 'confirm' ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-600'
}`}>
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
<div className="pointer-events-none fixed right-4 top-4 z-[120] flex w-[calc(100vw-2rem)] max-w-sm flex-col gap-2" aria-live="polite" aria-atomic="true">
{toasts.map((toast) => {
const style = toastStyles[toast.tone]
const ToastIcon = style.icon
return (
<div key={toast.id} className={`pointer-events-auto rounded-lg border bg-white shadow-lg dark:bg-gray-900 dark:shadow-black/40 ${style.borderClass}`} role="status">
<div className="flex items-start gap-3 p-3.5">
<div className={`mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full ${style.iconClass}`}>
<ToastIcon className="h-4 w-4" />
</div>
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
{dialog.type === 'alert' && (
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
<X className="w-4 h-4" />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-gray-900 dark:text-white">{t(toast.title)}</div>
<div className="mt-0.5 break-words text-sm leading-5 text-gray-600 dark:text-gray-300">{t(toast.message)}</div>
</div>
<button onClick={() => dismissToast(toast.id)} className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-black dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white" title={t('关闭')}>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
)
})}
</div>
{dialog.open && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 dark:bg-black/70">
<div className="w-full max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-center gap-3 border-b border-gray-100 px-5 py-4 dark:border-gray-700">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300">
<AlertTriangle className="h-4 w-4" />
</div>
<h3 className="flex-1 text-sm font-semibold text-black dark:text-white">{t(dialog.title)}</h3>
</div>
<div className="px-5 py-4">
<p className="text-sm text-gray-600">{t(dialog.message)}</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{t(dialog.message)}</p>
</div>
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
{dialog.type === 'confirm' && (
<div className="flex justify-end gap-2 border-t border-gray-100 bg-gray-50 px-5 py-3 dark:border-gray-700 dark:bg-gray-800">
<button
onClick={() => close(false)}
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
className="rounded-md px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
>
{t('取消')}
</button>
)}
<button
onClick={() => close(true)}
className={`px-4 py-2 text-sm rounded-md transition-colors ${
dialog.type === 'confirm'
? 'bg-black text-white hover:bg-gray-800'
: 'bg-black text-white hover:bg-gray-800'
}`}
className="rounded-md bg-black px-4 py-2 text-sm text-white transition-colors hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"
>
{dialog.type === 'confirm' ? t('确认') : t('确定')}
{t('确认')}
</button>
</div>
</div>
+14
View File
@@ -6,6 +6,7 @@ import {
Code2,
Cpu,
Camera,
HardDrive,
LayoutDashboard,
LogOut,
Moon,
@@ -83,6 +84,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
const isRoutingPage = location.pathname.startsWith('/routing')
const isStoragePage = location.pathname.startsWith('/storage')
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
const isHostReportPage = location.pathname.startsWith('/host-report')
@@ -201,6 +203,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/storage')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isStoragePage
? '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'
}`}
>
<HardDrive className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/audit-logs')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
+42
View File
@@ -83,6 +83,8 @@ body {
/* Shadow */
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
.dark .shadow-lg,
.dark .shadow-xl { box-shadow: 0 12px 28px rgba(0,0,0,0.45) !important; }
/* bg-black buttons in dark mode -> light */
.dark .bg-black { background-color: #f9fafb !important; }
@@ -121,6 +123,7 @@ body {
.dark .bg-amber-50 { background-color: #451a03 !important; }
.dark .bg-emerald-50 { background-color: #064e3b !important; }
.dark .bg-amber-100 { background-color: #78350f !important; }
.dark .bg-indigo-50 { background-color: #1e1b4b !important; }
/* Status badge text */
.dark .text-green-700 { color: #6ee7b7 !important; }
@@ -129,6 +132,14 @@ body {
.dark .text-amber-600 { color: #fcd34d !important; }
.dark .text-amber-700 { color: #fcd34d !important; }
.dark .text-emerald-700 { color: #6ee7b7 !important; }
.dark .text-emerald-600 { color: #6ee7b7 !important; }
.dark .text-amber-800 { color: #fde68a !important; }
.dark .text-indigo-700 { color: #a5b4fc !important; }
/* Colored notification borders */
.dark .border-emerald-200 { border-color: #065f46 !important; }
.dark .border-red-200 { border-color: #991b1b !important; }
.dark .border-amber-200 { border-color: #92400e !important; }
/* Focus ring */
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
@@ -137,6 +148,37 @@ body {
/* Accent */
.dark .accent-black { accent-color: #f9fafb !important; }
/* Native form controls */
.dark input,
.dark select,
.dark textarea { color-scheme: dark; }
/* Explicit dark variants take precedence over the compatibility overrides above. */
.dark .dark\:bg-white { background-color: #f9fafb !important; }
.dark .dark\:bg-gray-950 { background-color: #030712 !important; }
.dark .dark\:bg-gray-900 { background-color: #111827 !important; }
.dark .dark\:bg-gray-800 { background-color: #1f2937 !important; }
.dark .dark\:bg-gray-700 { background-color: #374151 !important; }
.dark .dark\:bg-emerald-950 { background-color: #022c22 !important; }
.dark .dark\:bg-red-950 { background-color: #450a0a !important; }
.dark .dark\:bg-amber-950 { background-color: #451a03 !important; }
.dark .dark\:text-white { color: #f9fafb !important; }
.dark .dark\:text-black { color: #111827 !important; }
.dark .dark\:text-gray-300 { color: #d1d5db !important; }
.dark .dark\:text-gray-400 { color: #9ca3af !important; }
.dark .dark\:text-gray-500 { color: #6b7280 !important; }
.dark .dark\:text-emerald-300 { color: #6ee7b7 !important; }
.dark .dark\:text-red-300 { color: #fca5a5 !important; }
.dark .dark\:text-amber-300 { color: #fcd34d !important; }
.dark .dark\:border-gray-700 { border-color: #374151 !important; }
.dark .dark\:border-emerald-800 { border-color: #065f46 !important; }
.dark .dark\:border-red-800 { border-color: #991b1b !important; }
.dark .dark\:border-amber-800 { border-color: #92400e !important; }
.dark .dark\:hover\:bg-gray-800:hover { background-color: #1f2937 !important; color: inherit !important; }
.dark .dark\:hover\:bg-gray-700:hover { background-color: #374151 !important; color: inherit !important; }
.dark .dark\:hover\:bg-gray-200:hover { background-color: #e5e7eb !important; color: #111827 !important; }
.dark .dark\:hover\:text-white:hover { color: #f9fafb !important; }
/* Spinner */
.dark .border-black { border-color: #f9fafb !important; }
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
+80 -7
View File
@@ -44,6 +44,7 @@ import {
getContainerSnapshots,
getContainerUsage,
getHostInfo,
getStorageInfo,
getTrafficInfo,
HostInfo,
TrafficInfo,
@@ -61,6 +62,7 @@ import {
stopContainer,
Snapshot,
SnapshotSchedule,
StorageInfo,
Template,
updateContainerExpiry,
updateFirewall,
@@ -75,6 +77,7 @@ import {
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import WebSSHViewer from '../components/WebSSHViewer'
import WebVNCViewer from '../components/WebVNCViewer'
import { RingStat } from '../components/RingStats'
@@ -127,6 +130,7 @@ export default function ContainerDetail() {
const navigate = useNavigate()
const dialog = useDialog()
const { isSubUser } = useAuth()
const { t } = useLanguage()
const [container, setContainer] = useState<Container | null>(null)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [usage, setUsage] = useState<ContainerUsage | null>(null)
@@ -182,6 +186,9 @@ export default function ContainerDetail() {
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
const [snapshotBusy, setSnapshotBusy] = useState('')
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
const [storageLoading, setStorageLoading] = useState(!isSubUser)
const [snapshotStoragePoolID, setSnapshotStoragePoolID] = useState('')
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
const [showFirewall, setShowFirewall] = useState(false)
@@ -224,6 +231,23 @@ export default function ContainerDetail() {
}
}, [containerIdentifier, container?.snapshot_limit])
const fetchStorage = useCallback(async () => {
if (isSubUser) {
setStorageLoading(false)
return
}
setStorageLoading(true)
try {
const res = await getStorageInfo()
setStorageInfo(res.data.data || null)
} catch (err) {
console.error('Failed to fetch storage:', err)
setStorageInfo(null)
} finally {
setStorageLoading(false)
}
}, [isSubUser])
const fetchMetricHistory = useCallback(async () => {
if (!containerIdentifier) return
try {
@@ -297,8 +321,11 @@ export default function ContainerDetail() {
}, [fetchMetricHistory])
useEffect(() => {
if (showSnapshots) fetchSnapshots()
}, [showSnapshots, fetchSnapshots])
if (showSnapshots) {
fetchSnapshots()
fetchStorage()
}
}, [showSnapshots, fetchSnapshots, fetchStorage])
// Poll task status for this container
useEffect(() => {
@@ -774,6 +801,10 @@ export default function ContainerDetail() {
const handleCreateSnapshot = async () => {
if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
if (!snapshotStorageReady) {
await dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
return
}
if (isSubUser && snapshots.length >= snapshotQuota) {
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
return
@@ -787,7 +818,7 @@ export default function ContainerDetail() {
}
setSnapshotBusy('create')
try {
await createContainerSnapshot(containerIdentifier)
await createContainerSnapshot(containerIdentifier, { storage_pool_id: snapshotStoragePoolID || undefined })
await Promise.all([fetchSnapshots(), fetchContainer()])
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
@@ -799,6 +830,10 @@ export default function ContainerDetail() {
const openSnapshotSchedule = () => {
if (isSubUser && container?.policy_blocked) return
if (!snapshotStorageReady) {
dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
return
}
setSnapshotScheduleDraft({
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
time: snapshotSchedule?.time || '03:00',
@@ -924,6 +959,10 @@ export default function ContainerDetail() {
const hasIndependentIPv4 = assignedIPv4List.length > 0
const hasIndependentIPv6 = ipv6List.length > 0
const defaultConnPort = isWindows ? 3389 : 22
const snapshotStoragePools = (storageInfo?.pools || []).filter((pool) =>
pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('snapshots')
)
const snapshotStorageReady = isSubUser || snapshotStoragePools.length > 0
let publicEndpoint = '-'
let sshCommand = ''
@@ -1231,8 +1270,8 @@ export default function ContainerDetail() {
<PlainRow label="vCPU" value={`${container.vcpu}`} />
<PlainRow label="内存" value={`${container.ram_mb} MB`} />
<PlainRow label="磁盘" value={`${container.disk_gb} GB`} />
<PlainRow label="网络速率" value={formatDirectionalLimit('下行', networkDownLimit, '上行', networkUpLimit, 'Mbps')} />
<PlainRow label="IO 速度" value={formatDirectionalLimit('读取', ioReadLimit, '写入', ioWriteLimit, 'MB/s')} />
<PlainRow label="网络速率" value={formatDirectionalLimit(t('下行'), networkDownLimit, t('上行'), networkUpLimit, 'Mbps')} />
<PlainRow label="IO 速度" value={formatDirectionalLimit(t('读取'), ioReadLimit, t('写入'), ioWriteLimit, 'MB/s')} />
</Panel>
<Panel title="实时状态">
@@ -1492,7 +1531,7 @@ export default function ContainerDetail() {
<div className="flex items-center gap-2">
<button
onClick={openSnapshotSchedule}
disabled={!!snapshotBusy}
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady}
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs ${
snapshotSchedule?.enabled
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
@@ -1504,7 +1543,7 @@ export default function ContainerDetail() {
</button>
<button
onClick={handleCreateSnapshot}
disabled={!!snapshotBusy || (isSubUser && snapshots.length >= snapshotQuota)}
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady || (isSubUser && snapshots.length >= snapshotQuota)}
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50"
>
<Camera className="w-3.5 h-3.5" />
@@ -1514,6 +1553,20 @@ export default function ContainerDetail() {
}
>
<div className="space-y-4">
{storageLoading && !isSubUser && (
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
<RefreshCw className="h-4 w-4 animate-spin" />
...
</div>
)}
{!storageLoading && !snapshotStorageReady && (
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<span></span>
<button onClick={() => { setShowSnapshots(false); navigate('/storage') }} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
</button>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-600">
<div>
@@ -1549,6 +1602,26 @@ export default function ContainerDetail() {
)}
</div>
{!isSubUser && snapshotStoragePools.length > 0 && (
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
<Field label="新建快照存储磁盘">
<select
value={snapshotStoragePoolID}
onChange={(event) => setSnapshotStoragePoolID(event.target.value)}
className="w-72 px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black"
>
<option value=""></option>
{snapshotStoragePools.map((pool) => (
<option key={pool.id} value={pool.id}>
{pool.name} · {pool.mount_point || pool.path}
</option>
))}
</select>
</Field>
<div className="pb-2 text-xs text-gray-400">使</div>
</div>
)}
{editingSnapshotQuota && !isSubUser && (
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
<Field label="子用户每台容器快照上限">
+35 -26
View File
@@ -21,6 +21,7 @@ import {
} from 'lucide-react'
import CreateContainerModal from '../components/CreateContainerModal'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import {
Container,
CreateContainerRequest,
@@ -391,7 +392,7 @@ export default function Containers() {
{pageContainers.map((container) => {
const isRunning = container.status === 'running'
const isInitializing = container.status === 'initializing'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const task = (container.id > 0 ? taskStatusMap[container.id] : undefined) || taskNameMap[container.name] || container.createTask
const isPlaceholder = !!container.isPlaceholder
const isPolicyBlocked = !!container.policy_blocked
const usage = usageByName[container.name]
@@ -581,12 +582,13 @@ type DisplayContainer = Container & {
}
function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
const { t } = useLanguage()
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
if (policyBlocked) {
return (
<span className={`${baseClass} bg-red-50 text-red-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
{t('策略封禁')}
</span>
)
}
@@ -595,7 +597,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-red-50 text-red-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
{t('初始化失败')}
</span>
)
}
@@ -604,16 +606,17 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-emerald-50 text-emerald-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
{t('初始化完成')}
</span>
)
}
if (task?.type === 'create' && task.status === 'running') {
const detail = t(task.stage_detail || '正在初始化')
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
<span className={`${baseClass} max-w-[210px] bg-amber-50 text-amber-700`} title={`${t('正在初始化')}: ${detail}`}>
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-amber-500 animate-pulse"></span>
<span className="truncate">{detail}</span>
</span>
)
}
@@ -622,7 +625,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-gray-100 text-gray-500`}>
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
{t('排队等待')}
</span>
)
}
@@ -634,7 +637,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
{taskLabels[task.type] || '处理中'}
{t(taskLabels[task.type] || '处理中')}
</span>
)
}
@@ -643,7 +646,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
{t('正在初始化')}
</span>
)
}
@@ -651,7 +654,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
{running ? '在线' : '离线'}
{t(running ? '在线' : '离线')}
</span>
)
}
@@ -788,7 +791,7 @@ type ContainerFilters = {
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
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : undefined) || filters.taskNameMap[container.name] || container.createTask
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
return false
}
@@ -865,6 +868,7 @@ function getContainerStatusFilterValue(container: DisplayContainer, task?: Task)
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
if (task.type === 'create' && task.status === 'done') return '初始化完成'
if (task.type === 'create' && task.status === 'running') return task.stage_detail || '正在初始化'
return actionLabels[task.type] || '处理中...'
}
@@ -873,13 +877,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
onRefresh: () => void | Promise<void>
onClose: () => void
}) {
const { t } = useLanguage()
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex max-h-[86vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-4">
<div>
<h2 className="text-base font-semibold text-black"></h2>
<p className="mt-0.5 text-xs text-gray-500"> {tasks.length} </p>
<h2 className="text-base font-semibold text-black">{t('任务队列')}</h2>
<p className="mt-0.5 text-xs text-gray-500">{t(`${tasks.length} 个任务`)}</p>
</div>
<div className="flex items-center gap-2">
<button
@@ -887,26 +892,27 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<RefreshCw className="h-4 w-4" />
{t('刷新')}
</button>
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title="关闭">
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title={t('关闭')}>
<X className="h-4 w-4" />
</button>
</div>
</div>
{tasks.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500"></div>
<div className="p-8 text-center text-sm text-gray-500">{t('暂无任务')}</div>
) : (
<div className="overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5">{t('状态')}</th>
<th className="whitespace-nowrap px-4 py-2.5">{t('操作')}</th>
<th className="whitespace-nowrap px-4 py-2.5">{t('容器')}</th>
<th className="whitespace-nowrap px-4 py-2.5">{t('当前阶段')}</th>
<th className="whitespace-nowrap px-4 py-2.5">{t('创建时间')}</th>
<th className="px-4 py-2.5">{t('错误')}</th>
<th className="whitespace-nowrap px-4 py-2.5 w-10"></th>
</tr>
</thead>
@@ -915,11 +921,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
<tr key={task.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-4 py-2.5">
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${taskStatusClass(task.status)}`}>
{taskStatusLabel(task.status)}
{t(taskStatusLabel(task.status))}
</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{actionLabel(task.type)}</td>
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{t(actionLabel(task.type))}</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-700">{task.container_name}</td>
<td className="min-w-[210px] px-4 py-2.5 text-xs text-gray-700">
{task.type === 'create' ? t(task.stage_detail || (task.status === 'pending' ? '排队等待' : '-')) : '-'}
</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-500">{task.created_at}</td>
<td className="min-w-[260px] px-4 py-2.5 text-gray-600">{task.error || '-'}</td>
<td className="whitespace-nowrap px-2 py-2.5">
@@ -932,7 +941,7 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
} catch { /* ignore */ }
}}
className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-red-600 transition-colors"
title="取消任务"
title={t('取消任务')}
>
<X className="w-3.5 h-3.5" />
</button>
+60 -7
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Download,
Trash2,
@@ -11,15 +12,18 @@ import {
AlertCircle,
X,
} from 'lucide-react'
import { getImages, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo } from '../services/api'
import { getImages, getStorageInfo, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo, StorageInfo } from '../services/api'
import { useDialog } from '../components/Dialog'
export default function ImageManagement() {
const dialog = useDialog()
const navigate = useNavigate()
const [images, setImages] = useState<ImageInfo[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState('')
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
const [storageLoading, setStorageLoading] = useState(true)
const fetchImages = useCallback(async () => {
try {
@@ -33,9 +37,22 @@ export default function ImageManagement() {
}
}, [])
const fetchStorage = useCallback(async () => {
setStorageLoading(true)
try {
const res = await getStorageInfo()
setStorageInfo(res.data.data || null)
} catch {
setStorageInfo(null)
} finally {
setStorageLoading(false)
}
}, [])
useEffect(() => {
fetchImages()
}, [fetchImages])
fetchStorage()
}, [fetchImages, fetchStorage])
useEffect(() => {
const hasDownloads = images.some((img) => img.downloading)
@@ -101,6 +118,9 @@ 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')
const imageStorageReady = (storageInfo?.pools || []).some((pool) =>
pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('images')
)
if (loading) {
return (
@@ -121,7 +141,7 @@ export default function ImageManagement() {
</p>
</div>
<button
onClick={fetchImages}
onClick={() => { fetchImages(); fetchStorage() }}
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium"
>
<RefreshCw className="w-3.5 h-3.5" />
@@ -136,6 +156,25 @@ export default function ImageManagement() {
</div>
)}
{storageLoading && (
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
...
</div>
)}
{!storageLoading && !imageStorageReady && (
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 shrink-0" />
</div>
<button onClick={() => navigate('/storage')} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
</button>
</div>
)}
<ImageTable
title="LXC 容器镜像"
images={lxcImages}
@@ -146,6 +185,8 @@ export default function ImageManagement() {
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
storageReady={imageStorageReady}
storageLoading={storageLoading}
/>
{kvmImages.length > 0 && (
@@ -159,6 +200,8 @@ export default function ImageManagement() {
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
storageReady={imageStorageReady}
storageLoading={storageLoading}
/>
)}
</div>
@@ -175,6 +218,8 @@ function ImageTable({
onCancelDownload,
onDelete,
onToggle,
storageReady,
storageLoading,
}: {
title: string
images: ImageInfo[]
@@ -185,6 +230,8 @@ function ImageTable({
onCancelDownload: (id: string) => void
onDelete: (id: string) => void
onToggle: (id: string, enabled: boolean) => void
storageReady: boolean
storageLoading: boolean
}) {
return (
<div className="space-y-3">
@@ -253,7 +300,8 @@ function ImageTable({
{!img.downloaded && !img.downloading && (
<button
onClick={() => onDownload(img.id)}
disabled={isBusy}
disabled={isBusy || storageLoading || !storageReady}
title={storageLoading ? '正在检查存储配置...' : storageReady ? '下载镜像' : '请先在存储管理中开启镜像缓存存储'}
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"
>
{isBusy ? (
@@ -281,7 +329,8 @@ function ImageTable({
<>
<button
onClick={() => onToggle(img.id, img.enabled)}
disabled={isBusy}
disabled={isBusy || storageLoading || !storageReady}
title={storageLoading ? '正在检查存储配置...' : storageReady ? (img.enabled ? '禁用镜像' : '启用镜像') : '请先在存储管理中开启镜像缓存存储'}
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
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100'
@@ -317,7 +366,7 @@ function ImageTable({
function StatusBadge({ img }: { img: ImageInfo }) {
if (img.downloading) {
const progress = Math.max(0, Math.min(100, img.progress || 0))
const showProgress = img.stage === 'downloading' && progress > 0
const showProgress = img.stage === 'downloading' && (progress > 0 || img.downloaded_bytes > 0)
return (
<div className="inline-flex flex-col gap-1">
<span
@@ -329,7 +378,10 @@ function StatusBadge({ img }: { img: ImageInfo }) {
</span>
{showProgress && (
<span className="block h-1 w-24 overflow-hidden rounded-full bg-amber-100">
<span className="block h-full rounded-full bg-amber-500 transition-all" style={{ width: `${progress}%` }} />
<span
className={`block h-full rounded-full bg-amber-500 transition-all ${progress <= 0 ? 'animate-pulse' : ''}`}
style={{ width: progress > 0 ? `${progress}%` : '35%' }}
/>
</span>
)}
</div>
@@ -375,6 +427,7 @@ function downloadStatusLabel(img: ImageInfo) {
if (img.stage === 'converting') return '转换中'
if (img.stage === 'lxc-create') return '下载中'
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
if (img.downloaded_bytes > 0) return `下载中 · ${formatSize(img.downloaded_bytes)}`
return '下载中'
}
+209 -49
View File
@@ -1,23 +1,38 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
import { Clock, Globe, ListTodo, Lock, LogIn, Minus, Monitor, Plus, RefreshCw, Save, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
getSSLSettings,
getTaskQueueSettings,
getWebSSHOriginSettings,
LoginLog,
SSLSettings,
TaskQueueSettings,
updateTaskQueueSettings,
updateSSLSettings,
updateWebSSHOriginSettings,
WebSSHOriginSettings,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
type SettingsSection = 'tasks' | 'account' | 'webssh' | 'ssl' | 'logs'
const settingsSections = [
{ id: 'tasks', label: '任务队列', icon: ListTodo },
{ id: 'account', label: '账号设置', icon: UserCog },
{ id: 'webssh', label: 'WebSSH 访问', icon: Terminal },
{ id: 'ssl', label: 'SSL 证书', icon: ShieldCheck },
{ id: 'logs', label: '登录日志', icon: LogIn },
] as const
export default function Settings() {
const dialog = useDialog()
const { username } = useAuth()
const { t } = useLanguage()
const [logs, setLogs] = useState<LoginLog[]>([])
const [loading, setLoading] = useState(true)
const [logPage, setLogPage] = useState(1)
@@ -39,6 +54,10 @@ export default function Settings() {
const [webSSHOrigins, setWebSSHOrigins] = useState<WebSSHOriginSettings | null>(null)
const [webSSHOriginsText, setWebSSHOriginsText] = useState('')
const [savingWebSSHOrigins, setSavingWebSSHOrigins] = useState(false)
const [taskQueue, setTaskQueue] = useState<TaskQueueSettings | null>(null)
const [taskConcurrency, setTaskConcurrency] = useState(2)
const [savingTaskQueue, setSavingTaskQueue] = useState(false)
const [activeSection, setActiveSection] = useState<SettingsSection>('tasks')
const fetchLogs = useCallback(async () => {
try {
@@ -78,13 +97,49 @@ export default function Settings() {
}
}, [])
const fetchTaskQueue = useCallback(async () => {
try {
const res = await getTaskQueueSettings()
const data = res.data.data
if (!data) return
setTaskQueue(data)
setTaskConcurrency(data.concurrency)
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
fetchLogs()
fetchSSL()
fetchWebSSHOrigins()
const timer = setInterval(fetchLogs, 15000)
return () => clearInterval(timer)
}, [fetchLogs, fetchSSL, fetchWebSSHOrigins])
fetchTaskQueue()
const logTimer = setInterval(fetchLogs, 15000)
const taskTimer = setInterval(fetchTaskQueue, 5000)
return () => {
clearInterval(logTimer)
clearInterval(taskTimer)
}
}, [fetchLogs, fetchSSL, fetchTaskQueue, fetchWebSSHOrigins])
const handleSaveTaskQueue = async () => {
const concurrency = Math.max(1, Math.min(16, Math.round(taskConcurrency || 1)))
setSavingTaskQueue(true)
try {
const res = await updateTaskQueueSettings(concurrency)
const data = res.data.data
if (data) {
setTaskQueue(data)
setTaskConcurrency(data.concurrency)
}
dialog.alert('完成', '任务队列并发设置已保存并立即生效')
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || '任务队列设置保存失败')
} finally {
setSavingTaskQueue(false)
}
}
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
setSSLMode(mode)
@@ -190,14 +245,86 @@ export default function Settings() {
const totalPages = Math.ceil(logs.length / pageSize)
return (
<div className="space-y-6">
<div className="space-y-5">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
<h1 className="text-2xl font-bold text-black dark:text-white"></h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">访</p>
</div>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<div className="space-y-6">
<div className="grid items-start gap-4 lg:grid-cols-[210px_minmax(0,1fr)]">
<aside className="overflow-x-auto rounded-lg border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-900 lg:sticky lg:top-4">
<nav className="flex min-w-max gap-1 lg:min-w-0 lg:flex-col" aria-label="设置分类">
{settingsSections.map((section) => {
const Icon = section.icon
const active = activeSection === section.id
return (
<button
key={section.id}
type="button"
onClick={() => setActiveSection(section.id)}
className={`flex items-center gap-2 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors ${active ? 'bg-black text-white dark:bg-white dark:text-black' : 'text-gray-600 hover:bg-gray-100 hover:text-black dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white'}`}
>
<Icon className="h-4 w-4 flex-shrink-0" />
<span>{t(section.label)}</span>
</button>
)
})}
</nav>
</aside>
<section className="min-w-0">
{activeSection === 'tasks' && (
<TaskQueueCard
settings={taskQueue}
concurrency={taskConcurrency}
saving={savingTaskQueue}
onConcurrencyChange={setTaskConcurrency}
onRefresh={fetchTaskQueue}
onSave={handleSaveTaskQueue}
/>
)}
{activeSection === 'account' && (
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-900">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
<UserCog className="h-4 w-4" />
</h2>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 3 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 6 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="输入当前密码以确认修改" />
</div>
</div>
<div className="mt-4 flex justify-end">
<button onClick={handleSaveAccount} className="rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"></button>
</div>
</div>
)}
{activeSection === 'webssh' && (
<WebSSHOriginCard
settings={webSSHOrigins}
originsText={webSSHOriginsText}
saving={savingWebSSHOrigins}
onOriginsTextChange={setWebSSHOriginsText}
onRefresh={fetchWebSSHOrigins}
onSave={handleSaveWebSSHOrigins}
/>
)}
{activeSection === 'ssl' && (
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
@@ -218,44 +345,73 @@ export default function Settings() {
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
)}
<WebSSHOriginCard
settings={webSSHOrigins}
originsText={webSSHOriginsText}
saving={savingWebSSHOrigins}
onOriginsTextChange={setWebSSHOriginsText}
onRefresh={fetchWebSSHOrigins}
onSave={handleSaveWebSSHOrigins}
/>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<UserCog className="h-4 w-4" />
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800"></button>
</div>
</div>
</div>
{activeSection === 'logs' && (
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
)}
</section>
</div>
</div>
)
}
interface TaskQueueCardProps {
settings: TaskQueueSettings | null
concurrency: number
saving: boolean
onConcurrencyChange: (value: number) => void
onRefresh: () => void
onSave: () => void
}
function TaskQueueCard(props: TaskQueueCardProps) {
const setBounded = (value: number) => props.onConcurrencyChange(Math.max(1, Math.min(16, value)))
return (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<ListTodo className="h-4 w-4" />
</h2>
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-2 divide-x divide-gray-200 border-y border-gray-100 bg-gray-50">
<div className="px-3 py-2">
<div className="text-[11px] text-gray-500"></div>
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.active ?? 0}</div>
</div>
<div className="px-3 py-2">
<div className="text-[11px] text-gray-500"></div>
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.pending ?? 0}</div>
</div>
</div>
<div className="mt-4">
<label className="mb-1.5 block text-xs text-gray-500"></label>
<div className="flex h-9 items-stretch">
<button type="button" onClick={() => setBounded(props.concurrency - 1)} disabled={props.concurrency <= 1} className="flex w-10 items-center justify-center rounded-l-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-30" title="减少并发">
<Minus className="h-4 w-4" />
</button>
<input
type="number"
min={1}
max={16}
value={props.concurrency}
onChange={(event) => setBounded(Number(event.target.value) || 1)}
className="min-w-0 flex-1 border-y border-gray-300 px-2 text-center text-sm font-medium text-black outline-none focus:ring-2 focus:ring-inset focus:ring-black"
/>
<button type="button" onClick={() => setBounded(props.concurrency + 1)} disabled={props.concurrency >= 16} className="flex w-10 items-center justify-center rounded-r-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-30" title="增加并发">
<Plus className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 flex justify-end">
<button onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Save className="h-4 w-4" />
{props.saving ? '保存中...' : '保存队列设置'}
</button>
</div>
</div>
)
}
@@ -292,7 +448,7 @@ interface WebSSHOriginCardProps {
function WebSSHOriginCard(props: WebSSHOriginCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<Terminal className="h-4 w-4" />WebSSH Origin
@@ -307,7 +463,7 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
<textarea
value={props.originsText}
onChange={(e) => props.onOriginsTextChange(e.target.value)}
rows={5}
rows={4}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black"
/>
</div>
@@ -315,12 +471,14 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
<div className="truncate font-mono" title={props.settings?.current_origin || ''}>{props.settings?.current_origin || '-'}</div>
<div className="mt-1"> Origin</div>
</div>
<button onClick={props.onSave} disabled={props.saving} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<div className="flex justify-end">
<button onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.saving ? '保存中...' : '保存 Origin 白名单'}
</button>
</div>
</div>
</div>
)
}
@@ -425,12 +583,14 @@ function SSLCard(props: SSLCardProps) {
</label>
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<div className="flex justify-end">
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
</button>
</div>
</div>
</div>
)
}
+369
View File
@@ -0,0 +1,369 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AlertCircle, CheckCircle2, HardDrive, RefreshCw, Save } from 'lucide-react'
import { getStorageInfo, updateStoragePools, StorageDisk, StorageInfo, StoragePool } from '../services/api'
import { useLanguage } from '../contexts/LanguageContext'
const contentOptions = [
['lxc', 'LXC 容器'],
['kvm', 'KVM 磁盘'],
['images', '镜像缓存'],
['snapshots', '快照'],
['backups', '备份'],
] as const
const contentLabels = Object.fromEntries(contentOptions)
const contentColors: Record<string, string> = {
lxc: '#2563eb',
kvm: '#7c3aed',
images: '#d97706',
snapshots: '#059669',
backups: '#0891b2',
}
export default function Storage() {
const { t } = useLanguage()
const [info, setInfo] = useState<StorageInfo | null>(null)
const [pools, setPools] = useState<StoragePool[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await getStorageInfo()
const data = res.data.data || { pools: [], disks: [], content_types: [] }
setInfo(data)
setPools(data.pools || [])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
useEffect(() => {
if (!saveMessage) return
const timer = window.setTimeout(() => setSaveMessage(null), 3500)
return () => window.clearTimeout(timer)
}, [saveMessage])
const mountedDisks = useMemo(() => (info?.disks || []).filter((disk) => !!disk.mount_point), [info?.disks])
const save = async () => {
setSaveMessage(null)
setSaving(true)
try {
const normalized = pools
.map((pool) => ({
...pool,
id: (pool.id || pool.name || '').trim(),
name: (pool.name || '').trim(),
path: (pool.path || '').trim(),
content_types: pool.content_types || [],
default_contents: (pool.default_contents || []).filter((item) => (pool.content_types || []).includes(item)),
enabled: pool.enabled !== false,
}))
const res = await updateStoragePools(normalized)
const data = res.data.data
if (data) {
setInfo(data)
setPools(data.pools || [])
}
setSaveMessage({ type: 'success', text: '存储配置已保存' })
} catch (err: any) {
setSaveMessage({ type: 'error', text: err?.response?.data?.message || '保存存储配置失败' })
} finally {
setSaving(false)
}
}
const updateDiskPool = (disk: StorageDisk, updater: (pool: StoragePool) => StoragePool) => {
setPools((current) => {
const index = current.findIndex((pool) => poolForDisk(pool, disk))
const base = index >= 0 ? current[index] : defaultPoolForDisk(disk)
const nextPool = updater(base)
if (index >= 0) {
return current.map((item, i) => i === index ? nextPool : item)
}
return [...current, nextPool]
})
}
const toggleContent = (disk: StorageDisk, content: string) => {
updateDiskPool(disk, (pool) => {
const current = pool.content_types || []
const enabled = current.includes(content)
const contentTypes = enabled ? current.filter((item) => item !== content) : [...current, content]
return {
...pool,
enabled: true,
content_types: contentTypes,
default_contents: (pool.default_contents || []).filter((item) => contentTypes.includes(item)),
}
})
}
const toggleDefault = (disk: StorageDisk, content: string) => {
setPools((current) => {
const index = current.findIndex((pool) => poolForDisk(pool, disk))
const base = index >= 0 ? current[index] : defaultPoolForDisk(disk)
if (!(base.content_types || []).includes(content)) return current
const hasDefault = (base.default_contents || []).includes(content)
const baseDefaults = (base.default_contents || []).filter((value) => value !== content)
const cleared = current.map((item) => ({
...item,
default_contents: (item.default_contents || []).filter((value) => value !== content),
}))
const nextPool = {
...base,
default_contents: hasDefault ? baseDefaults : [...baseDefaults, content],
}
if (index >= 0) {
return cleared.map((item, i) => i === index ? nextPool : item)
}
return [...cleared, nextPool]
})
}
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>
</div>
)
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-black dark:text-white">{t('存储管理')}</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{t('只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。')}</p>
</div>
<div className="flex gap-2">
<button onClick={fetchData} className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
<RefreshCw className="h-4 w-4" />{t('刷新')}
</button>
<button onClick={save} disabled={saving} className="inline-flex items-center gap-2 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Save className="h-4 w-4" />{t(saving ? '保存中...' : '保存')}
</button>
</div>
</div>
{saveMessage && (
<div
role="status"
aria-live="polite"
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${
saveMessage.type === 'success'
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-red-200 bg-red-50 text-red-700'
}`}
>
{saveMessage.type === 'success'
? <CheckCircle2 className="h-4 w-4 shrink-0" />
: <AlertCircle className="h-4 w-4 shrink-0" />}
<span>{t(saveMessage.text)}</span>
</div>
)}
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table className="w-full min-w-[1240px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-4 py-3 text-left font-medium">{t('磁盘')}</th>
<th className="px-4 py-3 text-left font-medium">{t('空间分布')}</th>
<th className="px-4 py-3 text-left font-medium">{t('用于存储')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{mountedDisks.length === 0 ? (
<tr><td colSpan={3} className="px-4 py-10 text-center text-gray-400">{t('未检测到已挂载磁盘')}</td></tr>
) : mountedDisks.map((disk) => {
const pool = pools.find((item) => poolForDisk(item, disk))
const contentUsage = contentUsageMap(pool?.content_usage || disk.content_usage || [])
const clicdUsed = pool?.clicd_used_bytes || disk.clicd_used_bytes || 0
return (
<tr key={`${disk.path}-${disk.mount_point}`} className="align-top hover:bg-gray-50/70">
<td className="px-4 py-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 items-center justify-center rounded-md bg-gray-100 text-gray-600">
<HardDrive className="h-5 w-5" />
</div>
<div>
<div className="font-mono text-xs font-medium text-gray-900">{disk.path || disk.name}</div>
<div className="mt-1 text-xs text-gray-500">{disk.model || disk.fstype || disk.type || '-'}</div>
<div className="mt-1 font-mono text-xs text-gray-400">{disk.mount_point}</div>
</div>
</div>
</td>
<td className="px-4 py-4">
<DiskUsageBar disk={disk} contentUsage={contentUsage} clicdUsed={clicdUsed} />
</td>
<td className="px-4 py-4">
<div className="flex min-w-[620px] flex-nowrap items-start gap-2">
{contentOptions.map(([value, label]) => {
const checked = (pool?.content_types || []).includes(value)
const isDefault = (pool?.default_contents || []).includes(value)
return (
<div key={value} className={`w-[116px] shrink-0 rounded-md border px-2.5 py-2 ${checked ? 'border-gray-300 bg-white' : 'border-gray-200 bg-gray-50'}`}>
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-700">
<input type="checkbox" checked={checked} onChange={() => toggleContent(disk, value)} />
{t(label)}
</label>
{checked && (
<div className="mt-1.5 flex items-center justify-between gap-2 border-t border-gray-100 pt-1.5">
<span className="text-[11px] text-gray-500">{t('默认盘')}</span>
<button
type="button"
role="switch"
aria-checked={isDefault}
title={isDefault ? `${t('关闭')} ${t(label)} ${t('默认盘')}` : `${t('设为')} ${t(label)} ${t('默认盘')}`}
onClick={() => toggleDefault(disk, value)}
className={`relative inline-flex h-5 w-9 shrink-0 appearance-none items-center rounded-full border p-0 transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 ${isDefault ? 'border-black bg-black' : 'border-gray-300 bg-gray-200'}`}
>
<span className={`pointer-events-none absolute left-0.5 top-0.5 block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${isDefault ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</div>
)}
</div>
)
})}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
function DiskUsageBar({
disk,
contentUsage,
clicdUsed,
}: {
disk: StorageDisk
contentUsage: Record<string, number>
clicdUsed: number
}) {
const { t } = useLanguage()
const total = Math.max(0, disk.size_bytes || 0)
const free = Math.max(0, Math.min(total, disk.free_bytes || 0))
const used = Math.max(0, total - free)
const rawContentSegments = contentOptions.map(([value, label]) => ({
key: value,
label,
size: Math.max(0, contentUsage[value] || 0),
color: contentColors[value],
}))
const rawContentTotal = rawContentSegments.reduce((sum, segment) => sum + segment.size, 0)
const normalizedClicdUsed = Math.max(0, Math.min(used, Math.max(clicdUsed || 0, rawContentTotal)))
const contentScale = rawContentTotal > normalizedClicdUsed && rawContentTotal > 0
? normalizedClicdUsed / rawContentTotal
: 1
const contentSegments = rawContentSegments.map((segment) => ({ ...segment, size: segment.size * contentScale }))
const categorizedClicdUsed = contentSegments.reduce((sum, segment) => sum + segment.size, 0)
const unclassifiedClicdUsed = Math.max(0, normalizedClicdUsed - categorizedClicdUsed)
const nonClicdUsed = Math.max(0, used - normalizedClicdUsed)
const segments = [
...contentSegments,
{ key: 'clicd-other', label: 'CLICD 其他', size: unclassifiedClicdUsed, color: '#111827' },
{ key: 'other', label: '非 CLICD', size: nonClicdUsed, color: '#4b5563' },
{ key: 'free', label: '可用空间', size: free, color: '#e5e7eb' },
].filter((segment) => segment.size > 0)
return (
<div className="min-w-[420px] max-w-[620px]">
<div className="flex items-center justify-between gap-4 text-xs text-gray-600">
<span>{t('已用')} {formatBytes(used)} / {formatBytes(total)}</span>
<span>{usagePct(used, total).toFixed(1)}% · {t('可用')} {formatBytes(free)}</span>
</div>
<div className="mt-2 flex h-8 w-full overflow-hidden rounded-md border border-gray-300 bg-gray-100">
{segments.map((segment) => {
const pct = usagePct(segment.size, total)
return (
<div
key={segment.key}
title={`${t(segment.label)}: ${formatBytes(segment.size)} (${pct.toFixed(2)}%)`}
className="flex h-full items-center justify-center overflow-hidden border-r border-white/70 text-[10px] font-medium text-white last:border-r-0"
style={{ width: `${pct}%`, minWidth: pct > 0 && pct < 0.6 ? '3px' : undefined, backgroundColor: segment.color }}
>
{pct >= 9 && <span className={segment.key === 'free' ? 'text-gray-600' : ''}>{t(segment.label)}</span>}
</div>
)
})}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1.5">
{segments.map((segment) => (
<div key={segment.key} className="flex items-center gap-1.5 text-[11px] text-gray-600">
<span className="h-2.5 w-2.5 shrink-0 rounded-sm border border-black/5" style={{ backgroundColor: segment.color }} />
<span>{t(segment.label)}</span>
<span className="font-medium text-gray-800">{formatBytes(segment.size)}</span>
<span className="text-gray-400">{usagePct(segment.size, total).toFixed(1)}%</span>
</div>
))}
</div>
</div>
)
}
function poolForDisk(pool: StoragePool, disk: StorageDisk) {
if (!disk.mount_point) return false
const mount = cleanPath(disk.mount_point)
const poolMount = cleanPath(pool.mount_point || '')
const poolPath = cleanPath(pool.path || '')
return poolMount === mount || poolPath === mount || poolPath.startsWith(`${mount}/`)
}
function defaultPoolForDisk(disk: StorageDisk): StoragePool {
const mount = cleanPath(disk.mount_point || '/')
const baseName = mount === '/' ? 'system' : mount.split('/').filter(Boolean).pop() || disk.name || 'disk'
const primaryContents = mount === '/' ? contentOptions.map(([value]) => value) : []
return {
id: `disk-${slugID(mount === '/' ? 'root' : baseName)}`,
name: `${baseName} (${disk.path || disk.name})`,
path: mount === '/' ? '/var/lib/clicd' : `${mount}/clicd`,
content_types: primaryContents,
default_contents: [...primaryContents],
enabled: true,
mount_point: disk.mount_point,
}
}
function cleanPath(value: string) {
return value.replace(/\\/g, '/').replace(/\/+$/g, '') || '/'
}
function slugID(value: string) {
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'storage'
}
function contentUsageMap(items: Array<{ content_type: string; size_bytes: number }>) {
return items.reduce<Record<string, number>>((acc, item) => {
acc[item.content_type] = (acc[item.content_type] || 0) + (item.size_bytes || 0)
return acc
}, {})
}
function usagePct(used: number, total: number) {
if (!total || total <= 0) return 0
return Math.max(0, Math.min(100, (used / total) * 100))
}
function formatBytes(bytes: number) {
if (!bytes) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index++
}
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}
+6 -2
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Copy, HardDrive, KeyRound, LogIn, RefreshCw, Save, ScrollText, UserCog, X } from 'lucide-react'
import { useDialog } from '../components/Dialog'
import { useLanguage } from '../contexts/LanguageContext'
import api, { AuditLog, ImageInfo, LoginLog, getImages, updateSubUserImages } from '../services/api'
import { copyToClipboard } from '../utils/clipboard'
@@ -31,6 +32,7 @@ interface AuditLogExt extends AuditLog {
export default function SubUserManagement() {
const dialog = useDialog()
const { t } = useLanguage()
const [users, setUsers] = useState<SubUserItem[]>([])
const [loading, setLoading] = useState(true)
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
@@ -173,8 +175,10 @@ export default function SubUserManagement() {
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>
<h1 className="text-xl font-semibold text-black dark:text-white">{t('子用户管理')}</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t('容器分配的子用户列表,共')} {users.length} {t('个')}
</p>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
+74 -2
View File
@@ -75,6 +75,8 @@ export interface Container {
uuid: string
name: string
virtualization?: string
storage_pool_id?: string
storage_path?: string
template: string
vcpu: number
ram_mb: number
@@ -143,6 +145,7 @@ export interface CreateContainerRequest {
name: string
virtualization: string
template_id: string
storage_pool_id?: string
vcpu: number
cpu_percent: number
ram_mb: number
@@ -180,6 +183,51 @@ export interface CreateContainerRequest {
expires_at: string
}
export interface StoragePool {
id: string
name: string
path: string
content_types: string[]
default_contents?: string[]
enabled: boolean
available?: boolean
exists?: boolean
size_bytes?: number
used_bytes?: number
free_bytes?: number
mount_point?: string
clicd_used_bytes?: number
content_usage?: StorageContentUsage[]
error?: string
}
export interface StorageContentUsage {
content_type: string
size_bytes: number
}
export interface StorageDisk {
name: string
path: string
type: string
fstype: string
mount_point: string
model: string
size_bytes: number
used_bytes: number
free_bytes: number
storage_pool_id?: string
storage_path?: string
clicd_used_bytes?: number
content_usage?: StorageContentUsage[]
}
export interface StorageInfo {
pools: StoragePool[]
disks: StorageDisk[]
content_types: string[]
}
export interface ReinstallContainerOptions {
ssh_auth_mode?: string
ssh_password?: string
@@ -258,6 +306,10 @@ export interface HostInfo {
}
}
export interface CreateSnapshotOptions {
storage_pool_id?: string
}
export interface HostMetricPoint {
ts: number
cpu: number
@@ -430,6 +482,18 @@ export interface AuditLog {
export const getLoginLogs = () =>
api.get<APIResponse<LoginLog[]>>('/login-logs')
export interface TaskQueueSettings {
concurrency: number
active: number
pending: number
}
export const getTaskQueueSettings = () =>
api.get<APIResponse<TaskQueueSettings>>('/task-queue/settings')
export const updateTaskQueueSettings = (concurrency: number) =>
api.put<APIResponse<TaskQueueSettings>>('/task-queue/settings', { concurrency })
export interface SSLCertificateInfo {
subject: string
issuer: string
@@ -741,6 +805,12 @@ export const getHostHistory = () =>
export const getHostReport = () =>
api.get<APIResponse<HostProbeReport>>('/host-report')
export const getStorageInfo = () =>
api.get<APIResponse<StorageInfo>>('/storage')
export const updateStoragePools = (pools: StoragePool[]) =>
api.put<APIResponse<StorageInfo>>('/storage', { pools })
// Snapshots
export interface Snapshot {
id: string
@@ -775,8 +845,8 @@ export const getSnapshots = () =>
export const getContainerSnapshots = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerSnapshotsResponse>>(`/containers/${id}/snapshots`)
export const createContainerSnapshot = (id: ContainerIdentifier) =>
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, {}, { timeout: 600000 })
export const createContainerSnapshot = (id: ContainerIdentifier, options?: CreateSnapshotOptions) =>
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, options || {}, { timeout: 600000 })
export const deleteContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
api.delete<APIResponse>(`/containers/${id}/snapshots/${snapshotId}`, { timeout: 600000 })
@@ -818,6 +888,8 @@ export interface Task {
container_name: string
status: string
error?: string
stage?: string
stage_detail?: string
created_at: string
template_id?: string
config?: CreateContainerRequest
+176
View File
@@ -392,6 +392,23 @@ const exact: Record<string, string> = {
'暂未获取到宿主机信息': 'No host information available',
'面板资源状态与容器概览': 'Panel resource status and container overview',
'宿主机资源状态与容器概览': 'Host resource status and container overview',
'存储管理': 'Storage Management',
'只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。': 'Only mounted disks are shown. Enable a content type to make that disk available to the corresponding feature.',
'空间分布': 'Space Distribution',
'用于存储': 'Storage Usage',
'未检测到已挂载磁盘': 'No mounted disks detected',
'镜像缓存': 'Image Cache',
'备份': 'Backups',
'默认盘': 'Default Disk',
'设为': 'Set as',
'CLICD 其他': 'Other CLICD Data',
'非 CLICD': 'Non-CLICD Data',
'可用空间': 'Free Space',
'存储配置已保存': 'Storage settings saved',
'保存存储配置失败': 'Failed to save storage settings',
'任务队列、账号、安全证书与访问记录': 'Task queue, account, certificates, and access records',
'设置分类': 'Settings categories',
'WebSSH 访问': 'WebSSH Access',
'账号设置': 'Account Settings',
'当前用户名': 'Current Username',
'新用户名,留空则不修改': 'New Username, leave blank to keep unchanged',
@@ -401,6 +418,12 @@ const exact: Record<string, string> = {
'至少 6 位': 'At least 6 characters',
'输入当前密码以确认修改': 'Enter current password to confirm changes',
'保存修改': 'Save Changes',
'总并发上限': 'Total Concurrency Limit',
'减少并发': 'Decrease concurrency',
'增加并发': 'Increase concurrency',
'保存队列设置': 'Save Queue Settings',
'任务队列并发设置已保存并立即生效': 'Task queue concurrency saved and applied immediately',
'任务队列设置保存失败': 'Failed to save task queue settings',
'SSL 证书': 'SSL Certificate',
'启用 HTTPS / WSS': 'Enable HTTPS / WSS',
'IP / 域名': 'IP / Domain',
@@ -761,6 +784,31 @@ const exact: Record<string, string> = {
'初始化失败': 'Initialization failed',
'初始化完成': 'Initialization complete',
'排队等待': 'Queued',
'当前阶段': 'Current Stage',
'准备初始化环境': 'Preparing initialization environment',
'检查模板与创建参数': 'Checking template and creation settings',
'下载模板并创建基础文件系统': 'Downloading template and creating root filesystem',
'复制容器数据到存储磁盘': 'Copying container data to storage disk',
'创建容量限制磁盘并复制 rootfs': 'Creating quota disk and copying rootfs',
'配置 CPU、内存与网络限制': 'Configuring CPU, memory, and network limits',
'分配 IPv4、IPv6 与 NAT 端口': 'Allocating IPv4, IPv6, and NAT ports',
'保存容器配置': 'Saving container configuration',
'写入容器网络配置': 'Writing container network configuration',
'安装并配置 SSH 服务': 'Installing and configuring SSH',
'转换非特权容器文件权限': 'Converting unprivileged container permissions',
'设置容器登录凭据': 'Setting container login credentials',
'启动容器并等待网络就绪': 'Starting container and waiting for network',
'启动虚拟机并等待网络就绪': 'Starting VM and waiting for network',
'检查 KVM 镜像与创建参数': 'Checking KVM image and creation settings',
'选择虚拟机存储磁盘': 'Selecting VM storage disk',
'分配 IPv4 与 IPv6 地址': 'Allocating IPv4 and IPv6 addresses',
'创建 Windows 虚拟磁盘': 'Creating Windows virtual disk',
'生成 Windows 自动应答配置': 'Generating Windows unattended setup',
'创建 KVM 系统磁盘': 'Creating KVM system disk',
'生成 cloud-init 初始化配置': 'Generating cloud-init configuration',
'注册 KVM 虚拟机': 'Registering KVM virtual machine',
'分配并配置 NAT 端口': 'Allocating and configuring NAT ports',
'保存虚拟机配置': 'Saving virtual machine configuration',
'处理中': 'Processing',
'未知系统': 'Unknown system',
'处理失败': 'Failed',
@@ -886,6 +934,132 @@ const exact: Record<string, string> = {
'生成新密码': 'Generate new password',
'自定义密码': 'Custom password',
'生成密码': 'Generate password',
'不限速': 'Unlimited',
'下': 'Down',
'不限': 'Unlimited',
'/ 上': '/ Up',
'请选择登录方式': 'Select a login method',
'未检测到可分配公网 IPv4': 'No allocatable public IPv4 detected',
'使用': 'Use',
'正在检测 IPv6 前缀...': 'Checking IPv6 prefixes...',
'公网 NAT': 'Public NAT',
'不分配 NAT 端口': 'Do not assign NAT ports',
'未检测到可分配 IPv6 前缀;宿主机只有 /128 单个 IPv6 地址,不能分配给容器。': 'No allocatable IPv6 prefix was detected. The host only has a single /128 IPv6 address, which cannot be assigned to containers.',
'宿主机检测到 IPv6 前缀,但 IPv6 出站连通性测试失败。': 'The host detected an IPv6 prefix, but the outbound IPv6 connectivity test failed.',
'个可分配地址': 'allocatable addresses',
'将分配': 'Will assign',
'请勾选任意一个可用网络': 'Select at least one available network',
'局域网 IPv4 配置有误': 'Invalid LAN IPv4 configuration',
'请填写有效的 IPv4 地址、子网掩码和网关': 'Enter a valid IPv4 address, subnet mask, and gateway',
'未配置存储': 'Storage not configured',
'请先在存储管理中为': 'In Storage Management, enable storage for',
'开启至少一块存储磁盘': 'Enable at least one storage disk',
'登录方式有误': 'Invalid login method',
'至': 'to',
'当前宿主机不支持 KVM': 'The current host does not support KVM',
'系统镜像,请先在「镜像管理」中下载镜像模板。': 'system images available. Download an image template from Images first.',
'存储磁盘': 'Storage Disk',
'自动选择(默认盘优先,空间不足自动切换)': 'Automatic selection (prefer default disk and switch when space is insufficient)',
'尚未开启': 'Not enabled',
'存储,当前无法创建。': 'storage is not enabled, so creation is currently unavailable.',
'去开启': 'Configure Now',
'默认勾选当前系统;取消后,子用户也不能重装该系统。': 'The current system is selected by default. Clearing it also prevents sub-users from reinstalling that system.',
'局域网 DHCP': 'LAN DHCP',
'macvlan 独立局域网 IP': 'Independent LAN IP via macvlan',
'未检测到可用上联网卡': 'No available uplink interface detected',
'DHCP 自动获取': 'Obtain automatically via DHCP',
'子网掩码': 'Subnet Mask',
'不选则长期有效': 'Leave blank for no expiration',
'均': 'Avg',
'/ 峰': '/ Peak',
'到期': 'Expires',
'未分配': 'Unassigned',
'下行': 'Download',
'上行': 'Upload',
'修改公网 IP 分配': 'Change Public IP Assignment',
'尚未开启快照存储,无法新建或启用定时快照。': 'Snapshot storage is not enabled. New and scheduled snapshots are unavailable.',
'新建快照存储磁盘': 'Storage Disk for New Snapshots',
'仅影响手动新建快照;定时快照使用默认磁盘。': 'Only affects manually created snapshots. Scheduled snapshots use the default disk.',
'在': 'at',
'IPv4 规则覆盖': 'IPv4 rules cover',
'独立公网 IPv4': 'independent public IPv4',
'公网 IP 分配': 'Public IP Assignment',
'修改后会重放端口映射、SNAT 和防火墙规则。': 'Changing assignments reapplies port mappings, SNAT, and firewall rules.',
'随机数量': 'Random Count',
'没有可选择的公网 IPv4,请先到路由管理配置 IPv4 池。': 'No public IPv4 addresses are available. Configure the IPv4 pool in Routing first.',
'独立 IPv6': 'Independent IPv6',
'自定义地址必须落在路由管理配置的 IPv6 前缀内。': 'Custom addresses must be within an IPv6 prefix configured in Routing.',
'未分配 IPv4 NAT 端口配额': 'No IPv4 NAT port quota assigned',
'已达到管理员分配的 IPv4 NAT 端口配额': 'The administrator-assigned IPv4 NAT port quota has been reached',
'不分配': 'Do Not Assign',
'随机分配': 'Random Allocation',
'自定义': 'Custom',
'SSH Key 格式不正确': 'Invalid SSH key format',
'公网 IP 分配失败': 'Public IP assignment failed',
'请检查地址是否可用或已被占用': 'Check whether the address is available or already in use',
'未分配 IPv4 NAT': 'IPv4 NAT not assigned',
'该容器未分配 IPv4 NAT 端口配额。': 'This container has no IPv4 NAT port quota.',
'未配置快照存储': 'Snapshot storage not configured',
'请先在存储管理中为快照开启至少一块存储磁盘。': 'Enable at least one snapshot storage disk in Storage Management first.',
'个月': 'months',
'个任务': 'tasks',
'剩余': 'Remaining',
'磨损': 'Wear',
'启停': 'Power Cycles',
'线程': 'threads',
'块硬盘': 'disks',
'个进程': 'processes',
'虚拟': 'Virtual',
'尚未开启镜像缓存存储,无法下载新镜像。': 'Image cache storage is not enabled, so new images cannot be downloaded.',
'请先在存储管理中开启镜像缓存存储': 'Enable image cache storage in Storage Management first',
'正在检查存储配置...': 'Checking storage configuration...',
'池内': 'In Pool',
'范围': 'Range',
'条映射': 'mappings',
'模式': 'Mode',
'NAT4、公网 IPv4 池和 IPv6 地址分配': 'NAT4, public IPv4 pool, and IPv6 address assignment',
'编辑 NAT4 范围': 'Edit NAT4 Range',
'起始端口': 'Start Port',
'结束端口': 'End Port',
'NAT4 范围必须是 1-65535,且起始端口不能大于结束端口': 'The NAT4 range must be within 1-65535, and the start port cannot exceed the end port',
'保存 NAT4 范围失败': 'Failed to save NAT4 range',
'剩余 / 总数': 'Remaining / Total',
'由局域网 DHCP 分配': 'Assigned by LAN DHCP',
'局域网 DHCP 分配': 'LAN DHCP Assignments',
'暂无局域网 DHCP 分配': 'No LAN DHCP assignments',
'公网 IPv4 池': 'Public IPv4 Pool',
'编辑 IP 池': 'Edit IP Pool',
'暂未配置公网 IPv4 池': 'No public IPv4 pool configured',
'掩码': 'Mask',
'分配给': 'Assigned To',
'空闲': 'Free',
'编辑 IPv4 池': 'Edit IPv4 Pool',
'IPv4 网关不能为空': 'IPv4 gateway is required',
'IPv4 地址不能为空': 'IPv4 address is required',
'保存 IPv4 池失败': 'Failed to save IPv4 pool',
'打开容器': 'Open Container',
'IPv4 池内暂无地址': 'No addresses in the IPv4 pool',
'添加 IPv4': 'Add IPv4',
'检测到的 IPv6 前缀': 'Detected IPv6 Prefixes',
'暂无 IPv6 前缀': 'No IPv6 prefixes',
'IPv6 网卡不能为空': 'IPv6 interface is required',
'本机': 'Local',
'暂无 IPv4 NAT 映射': 'No IPv4 NAT mappings',
'运行时名称': 'Runtime Name',
'客户机 IPv4': 'Guest IPv4',
'宿主 IPv4': 'Host IPv4',
'宿主端口': 'Host Port',
'客户机端口': 'Guest Port',
'大量': 'Large',
'的快照吗?此操作不可恢复。': ' snapshot? This action cannot be undone.',
'· 默认勾选当前系统,取消后将禁止重装该系统': ' · the current system is selected by default; clearing it prevents reinstalling that system',
'暂无已下载并启用的镜像': 'No downloaded and enabled images',
'已选择': 'Selected',
'加载失败': 'Loading failed',
'请填写 SSH 公钥': 'Enter an SSH public key',
'SSH 公钥长度不能超过 8192 字符': 'The SSH public key cannot exceed 8192 characters',
'SSH 公钥只能填写一行': 'The SSH public key must be on one line',
'SSH 公钥格式不正确': 'Invalid SSH public key format',
}
const artifactPatterns: RegExp[] = [
@@ -939,6 +1113,7 @@ const replacements: Array<[RegExp, string]> = [
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
[/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'],
[/共\s*(\d+)\s*个\s*任务/g, 'Total $1 tasks'],
[/(\d+)\s*个前缀,(\d+)\s*个地址已分配/g, '$1 prefixes, $2 addresses assigned'],
[/共\s*(\d+)\s*条/g, 'Total $1'],
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
@@ -988,6 +1163,7 @@ const replacements: Array<[RegExp, string]> = [
[/^(.+?)\s*-\s*登录日志$/g, '$1 - Login Logs'],
[/^(.+?)。下次登录生效$/g, '$1. Takes effect at next login'],
[/阶段:(.+)$/g, 'Stage: $1'],
[/正在初始化:(.+)$/g, 'Initializing: $1'],
[/\$\{days\}天/g, '${days} days'],
[/\$\{hours\}小时/g, '${hours} hours'],
[/\$\{hours\}\s*小时/g, '${hours} hours'],