mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-04 21:31:23 +08:00
@@ -309,6 +309,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateCreateStoragePool(&cfg); err != nil {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := validateCreateSSHAuth(cfg); err != nil {
|
if err := validateCreateSSHAuth(cfg); err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -41,6 +42,9 @@ type ImageInfo struct {
|
|||||||
|
|
||||||
var imageDownloadsMu sync.Mutex
|
var imageDownloadsMu sync.Mutex
|
||||||
var imageDownloads = map[string]*imageDownloadStatus{}
|
var imageDownloads = map[string]*imageDownloadStatus{}
|
||||||
|
var lxcImageCacheMu sync.Mutex
|
||||||
|
var lxcImageDownloadMu sync.Mutex
|
||||||
|
var lxcImageDownloadActive bool
|
||||||
|
|
||||||
type imageDownloadStatus struct {
|
type imageDownloadStatus struct {
|
||||||
Downloading bool
|
Downloading bool
|
||||||
@@ -136,6 +140,22 @@ func isImageDownloadActive(id string) bool {
|
|||||||
return st != nil && st.Downloading
|
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 {
|
func lxcImageDownloadTempName(id string) string {
|
||||||
return fmt.Sprintf("clicd-img-dl-%s", id)
|
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"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
image := kvm.FindImage(req.TemplateID)
|
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"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "KVM is not available on this host"})
|
||||||
return
|
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 {
|
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
||||||
ensureImageEnabled(image.ID)
|
ensureImageEnabled(image.ID)
|
||||||
clearImageDownload(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"})
|
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
||||||
return
|
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.
|
// Already downloaded? Just enable if needed.
|
||||||
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
|
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) {
|
go func(tmpl lxc.Template) {
|
||||||
|
defer endLXCImageDownload()
|
||||||
// Download via lxc-create with a temp container, then destroy it.
|
// Download via lxc-create with a temp container, then destroy it.
|
||||||
tmpName := lxcImageDownloadTempName(tmpl.ID)
|
tmpName := lxcImageDownloadTempName(tmpl.ID)
|
||||||
args := []string{"-n", tmpName, "-t", "download", "--",
|
args := []string{"-n", tmpName, "-t", "download", "--",
|
||||||
@@ -386,7 +433,7 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
st.Stage = "lxc-create"
|
st.Stage = "lxc-create"
|
||||||
})
|
})
|
||||||
cmd := exec.CommandContext(ctx, "lxc-create", args...)
|
cmd := exec.CommandContext(ctx, "lxc-create", args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := runLXCImageDownloadCommand(cmd, tmpl.ID)
|
||||||
|
|
||||||
// Clean up the temp container unconditionally.
|
// Clean up the temp container unconditionally.
|
||||||
cleanupLXCImageDownloadTemp(tmpl.ID)
|
cleanupLXCImageDownloadTemp(tmpl.ID)
|
||||||
@@ -403,10 +450,154 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
ensureImageEnabled(tmpl.ID)
|
ensureImageEnabled(tmpl.ID)
|
||||||
finishImageDownload(tmpl.ID, nil)
|
finishImageDownload(tmpl.ID, nil)
|
||||||
}(*tmpl)
|
}(*tmpl)
|
||||||
|
lxcDownloadHandedOff = true
|
||||||
|
|
||||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
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.
|
// HandleImageCancel cancels an in-progress image download.
|
||||||
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
|||||||
@@ -147,12 +147,12 @@ func trafficByRuntime(id int) map[string]interface{} {
|
|||||||
return lxcManager.GetTrafficInfo(id)
|
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)
|
c := config.FindContainer(id)
|
||||||
if c != nil && c.IsKVM() {
|
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 {
|
func deleteSnapshotByRuntime(snapshotID string) error {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"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)
|
// RecordLoginLog adds a login attempt to the log (persisted to config)
|
||||||
func RecordLoginLog(username, ip, userAgent string, success bool) {
|
func RecordLoginLog(username, ip, userAgent string, success bool) {
|
||||||
config.AddLoginLog(username, ip, userAgent, success)
|
config.AddLoginLog(username, ip, userAgent, success)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -88,6 +89,20 @@ func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID
|
|||||||
|
|
||||||
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
|
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
|
||||||
user := requestUser(r)
|
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) {
|
if isSubUserRequest(r) {
|
||||||
c := config.FindContainer(containerID)
|
c := config.FindContainer(containerID)
|
||||||
limit := config.ContainerSnapshotLimit(c)
|
limit := config.ContainerSnapshotLimit(c)
|
||||||
@@ -96,7 +111,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
|
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0, req.StoragePoolID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
@@ -159,6 +174,12 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
|
|||||||
if req.Time == "" {
|
if req.Time == "" {
|
||||||
req.Time = "03:00"
|
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)
|
user := requestUser(r)
|
||||||
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
|
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+328
-178
@@ -30,6 +30,8 @@ type Task struct {
|
|||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
Stage string `json:"stage,omitempty"`
|
||||||
|
StageDetail string `json:"stage_detail,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
TemplateID string `json:"template_id,omitempty"`
|
TemplateID string `json:"template_id,omitempty"`
|
||||||
Config lxc.ContainerConfig `json:"config,omitempty"`
|
Config lxc.ContainerConfig `json:"config,omitempty"`
|
||||||
@@ -37,30 +39,74 @@ type Task struct {
|
|||||||
User string `json:"user,omitempty"` // who created this task
|
User string `json:"user,omitempty"` // who created this task
|
||||||
IP string `json:"ip,omitempty"`
|
IP string `json:"ip,omitempty"`
|
||||||
UserAgent string `json:"user_agent,omitempty"`
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
|
activeKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskQueue struct {
|
type TaskQueue struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
createQueue []*Task
|
createQueue []*Task
|
||||||
opQueue []*Task
|
opQueue []*Task
|
||||||
tasks map[string]*Task
|
tasks map[string]*Task
|
||||||
nextID int
|
nextID int
|
||||||
createCond *sync.Cond
|
createCond *sync.Cond
|
||||||
opCond *sync.Cond
|
opCond *sync.Cond
|
||||||
stop chan struct{}
|
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
|
var globalQueue *TaskQueue
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
globalQueue = &TaskQueue{
|
globalQueue = newTaskQueue(config.DefaultTaskConcurrency)
|
||||||
tasks: make(map[string]*Task),
|
go globalQueue.createDispatcher()
|
||||||
stop: make(chan struct{}),
|
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)
|
q.createCond = sync.NewCond(&q.mu)
|
||||||
globalQueue.opCond = sync.NewCond(&globalQueue.mu)
|
q.opCond = sync.NewCond(&q.mu)
|
||||||
go globalQueue.createWorker()
|
return q
|
||||||
go globalQueue.opWorker()
|
}
|
||||||
|
|
||||||
|
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) {
|
func (q *TaskQueue) enqueueTask(task *Task) {
|
||||||
@@ -90,6 +136,8 @@ func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, task
|
|||||||
ContainerID: containerID,
|
ContainerID: containerID,
|
||||||
ContainerName: containerName,
|
ContainerName: containerName,
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
|
Stage: "queued",
|
||||||
|
StageDetail: "排队等待",
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
TemplateID: templateID,
|
TemplateID: templateID,
|
||||||
User: user,
|
User: user,
|
||||||
@@ -172,6 +220,8 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s
|
|||||||
ContainerID: 0,
|
ContainerID: 0,
|
||||||
ContainerName: cfgCopy.Name,
|
ContainerName: cfgCopy.Name,
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
|
Stage: "queued",
|
||||||
|
StageDetail: "排队等待",
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
Config: cfgCopy,
|
Config: cfgCopy,
|
||||||
User: user,
|
User: user,
|
||||||
@@ -202,6 +252,8 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
|
|||||||
ContainerID: containerID,
|
ContainerID: containerID,
|
||||||
ContainerName: containerName,
|
ContainerName: containerName,
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
|
Stage: "queued",
|
||||||
|
StageDetail: "排队等待",
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
TemplateID: templateID,
|
TemplateID: templateID,
|
||||||
User: user,
|
User: user,
|
||||||
@@ -262,176 +314,251 @@ func (q *TaskQueue) CancelPendingSecurityStops() int {
|
|||||||
return cancelled
|
return cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
|
// The two dispatchers keep long-running creates from blocking power operations,
|
||||||
// If a restored task already has a same-name container in config, it resumes
|
// while sharing one global concurrency budget.
|
||||||
// initialization instead of creating another ct-{id}.
|
func (q *TaskQueue) createDispatcher() {
|
||||||
func (q *TaskQueue) createWorker() {
|
|
||||||
for {
|
for {
|
||||||
q.mu.Lock()
|
task := q.takeNextTask(true)
|
||||||
for len(q.createQueue) == 0 {
|
go q.runCreateTask(task)
|
||||||
q.createCond.Wait()
|
|
||||||
}
|
|
||||||
task := q.createQueue[0]
|
|
||||||
q.createQueue = q.createQueue[1:]
|
|
||||||
task.Status = "running"
|
|
||||||
q.mu.Unlock()
|
|
||||||
|
|
||||||
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()
|
|
||||||
q.mu.Unlock()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
c := config.FindContainerByName(task.Config.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
|
|
||||||
}
|
|
||||||
createdByTask = true
|
|
||||||
|
|
||||||
// 2) Find created container by name
|
|
||||||
c = config.FindContainerByName(task.Config.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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, "成功", "admin")
|
|
||||||
}
|
|
||||||
|
|
||||||
q.mu.Lock()
|
|
||||||
q.persistTasks()
|
|
||||||
q.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// opWorker handles all non-create tasks (start, stop, restart, delete, reinstall)
|
func (q *TaskQueue) opDispatcher() {
|
||||||
// including the follow-up initialization after a create succeeds.
|
|
||||||
func (q *TaskQueue) opWorker() {
|
|
||||||
for {
|
for {
|
||||||
q.mu.Lock()
|
task := q.takeNextTask(false)
|
||||||
for len(q.opQueue) == 0 {
|
go q.runOperationTask(task)
|
||||||
q.opCond.Wait()
|
|
||||||
}
|
|
||||||
task := q.opQueue[0]
|
|
||||||
q.opQueue = q.opQueue[1:]
|
|
||||||
task.Status = "running"
|
|
||||||
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 {
|
|
||||||
if lxc.IsExpired(*c) {
|
|
||||||
err = fmt.Errorf("容器已到期,不允许此操作")
|
|
||||||
} else if lxc.IsTrafficExceeded(*c) {
|
|
||||||
err = fmt.Errorf("容器流量已超限,不允许此操作")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
|
|
||||||
skipped = true
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
if !skipped {
|
|
||||||
switch task.Type {
|
|
||||||
case TaskStart:
|
|
||||||
err = startByRuntime(task.ContainerID)
|
|
||||||
case TaskStop:
|
|
||||||
err = stopByRuntime(task.ContainerID)
|
|
||||||
case TaskRestart:
|
|
||||||
err = restartByRuntime(task.ContainerID)
|
|
||||||
case TaskDelete:
|
|
||||||
err = destroyByRuntime(task.ContainerID)
|
|
||||||
if err == nil {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
if config.FindContainer(task.ContainerID) != nil {
|
|
||||||
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case TaskReinstall:
|
|
||||||
if lxc.HasSSHAuthOptions(task.Config) {
|
|
||||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
|
||||||
} else {
|
|
||||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
|
|
||||||
} else {
|
|
||||||
task.Status = "done"
|
|
||||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
|
||||||
switch task.Type {
|
|
||||||
case TaskStart:
|
|
||||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
|
||||||
clearPolicyBlockAfterAdminRecovery(task)
|
|
||||||
case TaskStop:
|
|
||||||
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
|
||||||
case TaskRestart:
|
|
||||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
|
||||||
clearPolicyBlockAfterAdminRecovery(task)
|
|
||||||
case TaskReinstall:
|
|
||||||
clearPolicyBlockAfterAdminRecovery(task)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
q.persistTasks()
|
|
||||||
q.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
cfg := task.Config
|
||||||
|
q.mu.Unlock()
|
||||||
|
cfg.Progress = func(stage, detail string) {
|
||||||
|
q.updateTaskStage(task, stage, detail)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
if err := createByRuntime(cfg); err != nil {
|
||||||
|
config.AddAuditLog(string(task.Type), cfg.Name, "失败: "+err.Error(), "admin")
|
||||||
|
q.finishTask(task, "failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createdByTask = true
|
||||||
|
c = config.FindContainerByName(cfg.Name)
|
||||||
|
if c == nil {
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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.finishTask(task, "done", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) runOperationTask(task *Task) {
|
||||||
|
q.mu.Lock()
|
||||||
|
err := resolveTaskContainer(task)
|
||||||
|
q.mu.Unlock()
|
||||||
|
skipped := false
|
||||||
|
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
|
||||||
|
c := config.FindContainer(task.ContainerID)
|
||||||
|
if c != nil {
|
||||||
|
if lxc.IsExpired(*c) {
|
||||||
|
err = fmt.Errorf("容器已到期,不允许此操作")
|
||||||
|
} else if lxc.IsTrafficExceeded(*c) {
|
||||||
|
err = fmt.Errorf("容器流量已超限,不允许此操作")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
|
||||||
|
skipped = true
|
||||||
|
}
|
||||||
|
if err == nil && !skipped {
|
||||||
|
switch task.Type {
|
||||||
|
case TaskStart:
|
||||||
|
err = startByRuntime(task.ContainerID)
|
||||||
|
case TaskStop:
|
||||||
|
err = stopByRuntime(task.ContainerID)
|
||||||
|
case TaskRestart:
|
||||||
|
err = restartByRuntime(task.ContainerID)
|
||||||
|
case TaskDelete:
|
||||||
|
err = destroyByRuntime(task.ContainerID)
|
||||||
|
if err == nil {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
if config.FindContainer(task.ContainerID) != nil {
|
||||||
|
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case TaskReinstall:
|
||||||
|
if lxc.HasSSHAuthOptions(task.Config) {
|
||||||
|
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
||||||
|
} else {
|
||||||
|
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auditUser := task.User
|
||||||
|
if auditUser == "" {
|
||||||
|
auditUser = "admin"
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
|
||||||
|
q.finishTask(task, "failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if skipped {
|
||||||
|
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
|
||||||
|
q.finishTask(task, "done", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
||||||
|
switch task.Type {
|
||||||
|
case TaskStart:
|
||||||
|
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||||
|
clearPolicyBlockAfterAdminRecovery(task)
|
||||||
|
case TaskStop:
|
||||||
|
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
||||||
|
case TaskRestart:
|
||||||
|
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||||
|
clearPolicyBlockAfterAdminRecovery(task)
|
||||||
|
case TaskReinstall:
|
||||||
|
clearPolicyBlockAfterAdminRecovery(task)
|
||||||
|
}
|
||||||
|
q.finishTask(task, "done", nil)
|
||||||
|
}
|
||||||
|
|
||||||
func isSecurityStopTask(task *Task) bool {
|
func isSecurityStopTask(task *Task) bool {
|
||||||
return task != nil && task.Type == TaskStop && task.User == "system:security"
|
return task != nil && task.Type == TaskStop && task.User == "system:security"
|
||||||
}
|
}
|
||||||
@@ -503,7 +630,8 @@ func (q *TaskQueue) GetTasks() []*Task {
|
|||||||
result := make([]*Task, 0, len(q.tasks))
|
result := make([]*Task, 0, len(q.tasks))
|
||||||
// Collect all task IDs, sort by creation time (extracted from ID number)
|
// Collect all task IDs, sort by creation time (extracted from ID number)
|
||||||
for _, t := range q.tasks {
|
for _, t := range q.tasks {
|
||||||
result = append(result, t)
|
copyTask := *t
|
||||||
|
result = append(result, ©Task)
|
||||||
}
|
}
|
||||||
// Stable sort by ID number (task-N where N is sequential)
|
// Stable sort by ID number (task-N where N is sequential)
|
||||||
for i := 0; i < len(result); i++ {
|
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 {
|
if req.Containers[i].DiskGB < 1 {
|
||||||
req.Containers[i].DiskGB = 5
|
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) {
|
if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
||||||
return
|
return
|
||||||
@@ -879,6 +1011,8 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// RestoreTasks restores task queue from config
|
// RestoreTasks restores task queue from config
|
||||||
func RestoreTasks() {
|
func RestoreTasks() {
|
||||||
|
globalQueue.mu.Lock()
|
||||||
|
defer globalQueue.mu.Unlock()
|
||||||
for _, st := range config.AppConfig.Tasks {
|
for _, st := range config.AppConfig.Tasks {
|
||||||
if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown {
|
if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown {
|
||||||
continue
|
continue
|
||||||
@@ -908,6 +1042,8 @@ func RestoreTasks() {
|
|||||||
ContainerName: containerName,
|
ContainerName: containerName,
|
||||||
Status: st.Status,
|
Status: st.Status,
|
||||||
Error: st.Error,
|
Error: st.Error,
|
||||||
|
Stage: "queued",
|
||||||
|
StageDetail: "排队等待",
|
||||||
CreatedAt: st.CreatedAt,
|
CreatedAt: st.CreatedAt,
|
||||||
TemplateID: st.TemplateID,
|
TemplateID: st.TemplateID,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -937,3 +1073,17 @@ func parseIDNum(id string) int {
|
|||||||
}
|
}
|
||||||
return num
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -109,6 +112,8 @@ type Container struct {
|
|||||||
LXCName string `json:"lxc_name,omitempty"`
|
LXCName string `json:"lxc_name,omitempty"`
|
||||||
KVMName string `json:"kvm_name,omitempty"`
|
KVMName string `json:"kvm_name,omitempty"`
|
||||||
DiskImage string `json:"disk_image,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"`
|
MACAddress string `json:"mac_address,omitempty"`
|
||||||
Template string `json:"template"`
|
Template string `json:"template"`
|
||||||
VCPU float64 `json:"vcpu"`
|
VCPU float64 `json:"vcpu"`
|
||||||
@@ -202,6 +207,308 @@ func (c *Container) UsesLANIPv4() bool {
|
|||||||
return c.UsesLANDHCP() || c.UsesLANStaticIPv4()
|
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 {
|
func (c *Container) NormalizeNetworkAssignments() bool {
|
||||||
changed := false
|
changed := false
|
||||||
lanMode := strings.ToLower(strings.TrimSpace(c.LANIPv4Mode))
|
lanMode := strings.ToLower(strings.TrimSpace(c.LANIPv4Mode))
|
||||||
@@ -422,6 +729,43 @@ type SSLConfig struct {
|
|||||||
LastError string `json:"last_error,omitempty"`
|
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
|
// ClicdConfig is the main configuration structure
|
||||||
type ClicdConfig struct {
|
type ClicdConfig struct {
|
||||||
AdminUser string `json:"admin_user"`
|
AdminUser string `json:"admin_user"`
|
||||||
@@ -447,16 +791,24 @@ type ClicdConfig struct {
|
|||||||
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
|
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
|
||||||
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
||||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||||
|
TaskConcurrency int `json:"task_concurrency"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
SSL SSLConfig `json:"ssl"`
|
SSL SSLConfig `json:"ssl"`
|
||||||
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
|
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
|
||||||
|
StoragePools []StoragePool `json:"storage_pools"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var configPath string
|
var configPath string
|
||||||
var AppConfig *ClicdConfig
|
var AppConfig *ClicdConfig
|
||||||
|
var allocationMu sync.Mutex
|
||||||
|
|
||||||
const DefaultSnapshotLimit = 3
|
const DefaultSnapshotLimit = 3
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultTaskConcurrency = 2
|
||||||
|
MaxTaskConcurrency = 16
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultNATPortStart = 20000
|
DefaultNATPortStart = 20000
|
||||||
DefaultNATPortEnd = 65535
|
DefaultNATPortEnd = 65535
|
||||||
@@ -588,6 +940,8 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
PublicIPv4Pool: []PublicIPv4Assignment{},
|
PublicIPv4Pool: []PublicIPv4Assignment{},
|
||||||
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
||||||
WebSSHAllowedOrigins: []string{},
|
WebSSHAllowedOrigins: []string{},
|
||||||
|
TaskConcurrency: DefaultTaskConcurrency,
|
||||||
|
StoragePools: []StoragePool{defaultPrimaryStoragePool()},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
@@ -629,6 +983,10 @@ func normalizeConfigDefaults(dataDir string) bool {
|
|||||||
AppConfig.NextContainerID = 1
|
AppConfig.NextContainerID = 1
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if normalized := NormalizeTaskConcurrency(AppConfig.TaskConcurrency); AppConfig.TaskConcurrency != normalized {
|
||||||
|
AppConfig.TaskConcurrency = normalized
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if AppConfig.DataDir == "" {
|
if AppConfig.DataDir == "" {
|
||||||
AppConfig.DataDir = dataDir
|
AppConfig.DataDir = dataDir
|
||||||
changed = true
|
changed = true
|
||||||
@@ -656,6 +1014,13 @@ func normalizeConfigDefaults(dataDir string) bool {
|
|||||||
AppConfig.WebSSHAllowedOrigins = normalized
|
AppConfig.WebSSHAllowedOrigins = normalized
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if len(AppConfig.StoragePools) == 0 {
|
||||||
|
AppConfig.StoragePools = []StoragePool{defaultPrimaryStoragePool()}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if normalizeStoragePools() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if AppConfig.SubUsers == nil {
|
if AppConfig.SubUsers == nil {
|
||||||
AppConfig.SubUsers = make([]SubUser, 0)
|
AppConfig.SubUsers = make([]SubUser, 0)
|
||||||
changed = true
|
changed = true
|
||||||
@@ -701,6 +1066,16 @@ func normalizeConfigDefaults(dataDir string) bool {
|
|||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NormalizeTaskConcurrency(value int) int {
|
||||||
|
if value <= 0 {
|
||||||
|
return DefaultTaskConcurrency
|
||||||
|
}
|
||||||
|
if value > MaxTaskConcurrency {
|
||||||
|
return MaxTaskConcurrency
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func NormalizeLanguage(language string) string {
|
func NormalizeLanguage(language string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(language)) {
|
switch strings.ToLower(strings.TrimSpace(language)) {
|
||||||
case "en", "en-us", "en_us", "english":
|
case "en", "en-us", "en_us", "english":
|
||||||
@@ -1047,6 +1422,8 @@ func SaveConfig() error {
|
|||||||
|
|
||||||
// AddContainer adds a container to the config
|
// AddContainer adds a container to the config
|
||||||
func AddContainer(c Container) {
|
func AddContainer(c Container) {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
if c.UUID == "" {
|
if c.UUID == "" {
|
||||||
c.UUID = NewContainerUUID()
|
c.UUID = NewContainerUUID()
|
||||||
}
|
}
|
||||||
@@ -1058,6 +1435,8 @@ func AddContainer(c Container) {
|
|||||||
|
|
||||||
// AllocateContainerID allocates a new container ID
|
// AllocateContainerID allocates a new container ID
|
||||||
func AllocateContainerID() int {
|
func AllocateContainerID() int {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
id := AppConfig.NextContainerID
|
id := AppConfig.NextContainerID
|
||||||
AppConfig.NextContainerID++
|
AppConfig.NextContainerID++
|
||||||
SaveConfig()
|
SaveConfig()
|
||||||
@@ -1334,6 +1713,8 @@ func normalizeNATPortRangeDefaults() bool {
|
|||||||
|
|
||||||
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
||||||
func AllocateSSHPort() (int, error) {
|
func AllocateSSHPort() (int, error) {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
used := collectAllHostPorts()
|
used := collectAllHostPorts()
|
||||||
start, end := NATPortRange()
|
start, end := NATPortRange()
|
||||||
port := AppConfig.NextSSHPort
|
port := AppConfig.NextSSHPort
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ type savedTaskConfig struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Virtualization string `json:"virtualization,omitempty"`
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
|
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||||
VCPU float64 `json:"vcpu"`
|
VCPU float64 `json:"vcpu"`
|
||||||
CPUPercent int `json:"cpu_percent"`
|
CPUPercent int `json:"cpu_percent"`
|
||||||
RAMMB int `json:"ram_mb"`
|
RAMMB int `json:"ram_mb"`
|
||||||
@@ -190,6 +191,8 @@ func ensureSchema() error {
|
|||||||
lxc_name TEXT,
|
lxc_name TEXT,
|
||||||
kvm_name TEXT,
|
kvm_name TEXT,
|
||||||
disk_image TEXT,
|
disk_image TEXT,
|
||||||
|
storage_pool_id TEXT,
|
||||||
|
storage_path TEXT,
|
||||||
mac_address TEXT,
|
mac_address TEXT,
|
||||||
template TEXT,
|
template TEXT,
|
||||||
vcpu REAL,
|
vcpu REAL,
|
||||||
@@ -461,6 +464,8 @@ func ensureSchemaMigrations() error {
|
|||||||
{"containers", "allowed_image_ids", "TEXT"},
|
{"containers", "allowed_image_ids", "TEXT"},
|
||||||
{"containers", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
|
{"containers", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"containers", "restore_on_host_boot", "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_ipv4_mode", "TEXT"},
|
||||||
{"containers", "lan_interface", "TEXT"},
|
{"containers", "lan_interface", "TEXT"},
|
||||||
{"containers", "lan_ipv4_address", "TEXT NOT NULL DEFAULT ''"},
|
{"containers", "lan_ipv4_address", "TEXT NOT NULL DEFAULT ''"},
|
||||||
@@ -515,6 +520,11 @@ func ensureSchemaMigrations() error {
|
|||||||
lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil {
|
lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil {
|
||||||
return err
|
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
|
if _, err := db.Exec(`UPDATE tasks
|
||||||
SET cfg_lan_ipv4_mode = COALESCE(cfg_lan_ipv4_mode, ''),
|
SET cfg_lan_ipv4_mode = COALESCE(cfg_lan_ipv4_mode, ''),
|
||||||
cfg_lan_interface = COALESCE(cfg_lan_interface, ''),
|
cfg_lan_interface = COALESCE(cfg_lan_interface, ''),
|
||||||
@@ -585,6 +595,7 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
|||||||
NATPortEnd: atoi(meta["nat_port_end"]),
|
NATPortEnd: atoi(meta["nat_port_end"]),
|
||||||
SetupComplete: atob(meta["setup_complete"]),
|
SetupComplete: atob(meta["setup_complete"]),
|
||||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||||
|
TaskConcurrency: atoi(meta["task_concurrency"]),
|
||||||
Language: meta["language"],
|
Language: meta["language"],
|
||||||
}
|
}
|
||||||
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
|
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 != "" {
|
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
|
||||||
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
|
_ = 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 {
|
if cfg.Containers, err = loadContainers(); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
@@ -701,6 +715,7 @@ func saveMeta(tx *sql.Tx) error {
|
|||||||
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
|
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
|
||||||
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
|
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
|
||||||
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
|
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
|
||||||
|
storagePoolsJSON, _ := json.Marshal(AppConfig.StoragePools)
|
||||||
values := map[string]string{
|
values := map[string]string{
|
||||||
"admin_user": AppConfig.AdminUser,
|
"admin_user": AppConfig.AdminUser,
|
||||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||||
@@ -714,12 +729,14 @@ func saveMeta(tx *sql.Tx) error {
|
|||||||
"nat_port_end": strconv.Itoa(AppConfig.NATPortEnd),
|
"nat_port_end": strconv.Itoa(AppConfig.NATPortEnd),
|
||||||
"setup_complete": btoa(AppConfig.SetupComplete),
|
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||||
|
"task_concurrency": strconv.Itoa(AppConfig.TaskConcurrency),
|
||||||
"language": NormalizeLanguage(AppConfig.Language),
|
"language": NormalizeLanguage(AppConfig.Language),
|
||||||
"ssl": string(sslJSON),
|
"ssl": string(sslJSON),
|
||||||
"ssl_certificates": string(sslCertificatesJSON),
|
"ssl_certificates": string(sslCertificatesJSON),
|
||||||
"public_ipv4_pool": string(publicIPv4PoolJSON),
|
"public_ipv4_pool": string(publicIPv4PoolJSON),
|
||||||
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
|
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
|
||||||
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
|
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
|
||||||
|
"storage_pools": string(storagePoolsJSON),
|
||||||
"schema_version": "1",
|
"schema_version": "1",
|
||||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
@@ -736,7 +753,7 @@ func saveContainers(tx *sql.Tx) error {
|
|||||||
NormalizeContainerResourceAliases(&c)
|
NormalizeContainerResourceAliases(&c)
|
||||||
allowedImageIDs := encodeStringSlice(c.AllowedImageIDs)
|
allowedImageIDs := encodeStringSlice(c.AllowedImageIDs)
|
||||||
if _, err := tx.Exec(`INSERT INTO containers (
|
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,
|
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
|
||||||
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
|
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,
|
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||||
firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured
|
firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
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, c.StoragePoolID, c.StoragePath, c.MACAddress, c.Template,
|
||||||
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
|
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
|
||||||
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
||||||
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate,
|
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate,
|
||||||
@@ -957,7 +974,7 @@ func saveSnapshots(tx *sql.Tx) error {
|
|||||||
|
|
||||||
func loadContainers() ([]Container, error) {
|
func loadContainers() ([]Container, error) {
|
||||||
rows, err := db.Query(`SELECT
|
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,
|
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
|
||||||
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
|
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 scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured, restoreOnHostBoot int
|
||||||
var firewallDefaultAction string
|
var firewallDefaultAction string
|
||||||
var firewallRulesJSON, allowedImageIDs sql.NullString
|
var firewallRulesJSON, allowedImageIDs sql.NullString
|
||||||
|
var storagePoolID, storagePath sql.NullString
|
||||||
var lanIPv4Mode, lanInterface sql.NullString
|
var lanIPv4Mode, lanInterface sql.NullString
|
||||||
var lanIPv4Address, lanIPv4Gateway sql.NullString
|
var lanIPv4Address, lanIPv4Gateway sql.NullString
|
||||||
var lanIPv4PrefixLen sql.NullInt64
|
var lanIPv4PrefixLen sql.NullInt64
|
||||||
if err := rows.Scan(
|
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.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps,
|
||||||
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
||||||
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
|
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
|
||||||
@@ -1000,6 +1018,8 @@ func loadContainers() ([]Container, error) {
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
c.StoragePoolID = storagePoolID.String
|
||||||
|
c.StoragePath = storagePath.String
|
||||||
c.LANIPv4Mode = lanIPv4Mode.String
|
c.LANIPv4Mode = lanIPv4Mode.String
|
||||||
c.LANInterface = lanInterface.String
|
c.LANInterface = lanInterface.String
|
||||||
c.LANIPv4Address = lanIPv4Address.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]`) {
|
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)
|
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 {
|
if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil {
|
||||||
t.Fatalf("sqlite database was not created: %v", err)
|
t.Fatalf("sqlite database was not created: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.Containers[0].Status = "stopped"
|
cfg.Containers[0].Status = "stopped"
|
||||||
|
cfg.TaskConcurrency = 6
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -111,6 +115,9 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
|||||||
if got := cfg.Containers[0].Status; got != "stopped" {
|
if got := cfg.Containers[0].Status; got != "stopped" {
|
||||||
t.Fatalf("expected sqlite value to win after migration, got %q", got)
|
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) {
|
func resetConfigStoreForTest(t *testing.T) {
|
||||||
|
|||||||
+113
-12
@@ -95,6 +95,9 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func BaseDir() string {
|
func BaseDir() string {
|
||||||
|
if pool := config.PreferredStoragePoolForContent(config.StorageContentKVM); pool != nil {
|
||||||
|
return filepath.Join(pool.Path, "kvm")
|
||||||
|
}
|
||||||
return "/var/lib/clicd/kvm"
|
return "/var/lib/clicd/kvm"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,11 +105,30 @@ func NewManager() *Manager {
|
|||||||
return &Manager{BasePath: BaseDir()}
|
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 {
|
func (m *Manager) instancesDir() string {
|
||||||
return filepath.Join(m.BasePath, "instances")
|
return filepath.Join(m.BasePath, "instances")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) instanceDir(name string) string {
|
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)
|
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 {
|
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
|
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 {
|
if ok, _ := ImageDownloadedInfo(image.ID); ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -348,6 +379,7 @@ func normalizeQCOW2(ctx context.Context, src, target string) error {
|
|||||||
|
|
||||||
func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
||||||
cfg.NormalizeResourceAliases()
|
cfg.NormalizeResourceAliases()
|
||||||
|
cfg.ReportProgress("preparing", "检查 KVM 镜像与创建参数")
|
||||||
image := FindImage(cfg.TemplateID)
|
image := FindImage(cfg.TemplateID)
|
||||||
if image == nil {
|
if image == nil {
|
||||||
return fmt.Errorf("KVM image not found: %s", cfg.TemplateID)
|
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)) {
|
if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) {
|
||||||
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
|
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 {
|
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||||
cfg.PortMappingCount = 2
|
cfg.PortMappingCount = 2
|
||||||
} else if !cfg.WantsNAT() {
|
} else if !cfg.WantsNAT() {
|
||||||
@@ -424,6 +472,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
sshPublicKey = sshAccess.PublicKey
|
sshPublicKey = sshAccess.PublicKey
|
||||||
sshAuthMode = sshAccess.Mode
|
sshAuthMode = sshAccess.Mode
|
||||||
}
|
}
|
||||||
|
cfg.ReportProgress("addresses", "分配 IPv4 与 IPv6 地址")
|
||||||
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -451,6 +500,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
if cfg.DiskGB < 30 {
|
if cfg.DiskGB < 30 {
|
||||||
cfg.DiskGB = 30
|
cfg.DiskGB = 30
|
||||||
}
|
}
|
||||||
|
cfg.ReportProgress("disk", "创建 Windows 虚拟磁盘")
|
||||||
if err := createEmptyDisk(diskPath, cfg.DiskGB); err != nil {
|
if err := createEmptyDisk(diskPath, cfg.DiskGB); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -459,6 +509,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
}
|
}
|
||||||
winAdminPassword = generateWindowsPassword()
|
winAdminPassword = generateWindowsPassword()
|
||||||
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
|
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 {
|
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -472,9 +523,11 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
cfg.DiskGB = 20
|
cfg.DiskGB = 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cfg.ReportProgress("disk", "创建 KVM 系统磁盘")
|
||||||
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
|
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
|
||||||
return nil, err
|
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 {
|
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, ipv4List, *image, sshAuthMode); err != nil {
|
||||||
return nil, err
|
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 {
|
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
cfg.ReportProgress("define", "注册 KVM 虚拟机")
|
||||||
cmd := exec.Command("virsh", "define", xmlPath)
|
cmd := exec.Command("virsh", "define", xmlPath)
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
return nil, fmt.Errorf("virsh define failed: %v, output: %s", err, string(output))
|
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
|
sshPort := 0
|
||||||
portMappings := []config.PortMapping{}
|
portMappings := []config.PortMapping{}
|
||||||
if allocatePorts && cfg.WantsNAT() {
|
if allocatePorts && cfg.WantsNAT() {
|
||||||
|
cfg.ReportProgress("nat", "分配并配置 NAT 端口")
|
||||||
sshPort, err = config.AllocateSSHPort()
|
sshPort, err = config.AllocateSSHPort()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -538,6 +593,12 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
if trafficMode == "" {
|
if trafficMode == "" {
|
||||||
trafficMode = "total"
|
trafficMode = "total"
|
||||||
}
|
}
|
||||||
|
storagePoolID := cfg.StoragePoolID
|
||||||
|
if storagePoolID == "" {
|
||||||
|
if pool := config.DefaultStoragePoolForContent(config.StorageContentKVM); pool != nil {
|
||||||
|
storagePoolID = pool.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
container := &config.Container{
|
container := &config.Container{
|
||||||
ID: id,
|
ID: id,
|
||||||
UUID: config.NewContainerUUID(),
|
UUID: config.NewContainerUUID(),
|
||||||
@@ -545,6 +606,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
Virtualization: config.VirtualizationKVM,
|
Virtualization: config.VirtualizationKVM,
|
||||||
KVMName: vmName,
|
KVMName: vmName,
|
||||||
DiskImage: diskPath,
|
DiskImage: diskPath,
|
||||||
|
StoragePoolID: storagePoolID,
|
||||||
|
StoragePath: m.instanceDir(vmName),
|
||||||
MACAddress: mac,
|
MACAddress: mac,
|
||||||
Template: cfg.TemplateID,
|
Template: cfg.TemplateID,
|
||||||
VCPU: cfg.VCPU,
|
VCPU: cfg.VCPU,
|
||||||
@@ -580,6 +643,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
ExpiresAt: cfg.ExpiresAt,
|
ExpiresAt: cfg.ExpiresAt,
|
||||||
}
|
}
|
||||||
container.NormalizeNetworkAssignments()
|
container.NormalizeNetworkAssignments()
|
||||||
|
cfg.ReportProgress("metadata", "保存虚拟机配置")
|
||||||
return container, nil
|
return container, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,7 +1018,7 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error {
|
|||||||
return nil
|
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()
|
kvmSnapshotMu.Lock()
|
||||||
defer kvmSnapshotMu.Unlock()
|
defer kvmSnapshotMu.Unlock()
|
||||||
|
|
||||||
@@ -980,17 +1044,26 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
|
|||||||
|
|
||||||
name := c.VirshName()
|
name := c.VirshName()
|
||||||
instanceDir := m.instanceDir(name)
|
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
|
return config.Snapshot{}, err
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(instanceDir); err != nil {
|
if _, err := os.Stat(instanceDir); err != nil {
|
||||||
return config.Snapshot{}, fmt.Errorf("VM storage not found: %v", err)
|
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()
|
now := time.Now()
|
||||||
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
||||||
snapshotDir := filepath.Join(snapshotBaseDir(), "kvm", strconv.Itoa(id), snapshotID)
|
baseDir := filepath.Join(pool.Path, "snapshots")
|
||||||
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
|
snapshotDir := filepath.Join(baseDir, "kvm", strconv.Itoa(id), snapshotID)
|
||||||
|
if err := safePathUnder(snapshotDir, baseDir); err != nil {
|
||||||
return config.Snapshot{}, err
|
return config.Snapshot{}, err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
|
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 {
|
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
|
||||||
if snapshot.Path != "" {
|
if snapshot.Path != "" {
|
||||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
if err := safeSnapshotPath(snapshot.Path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.RemoveAll(snapshot.Path); err != nil {
|
if err := os.RemoveAll(snapshot.Path); err != nil {
|
||||||
@@ -1065,7 +1138,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
|
|||||||
if snapshot.Path == "" {
|
if snapshot.Path == "" {
|
||||||
return fmt.Errorf("snapshot path is empty")
|
return fmt.Errorf("snapshot path is empty")
|
||||||
}
|
}
|
||||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
if err := safeSnapshotPath(snapshot.Path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(snapshot.Path); err != nil {
|
if _, err := os.Stat(snapshot.Path); err != nil {
|
||||||
@@ -1081,7 +1154,8 @@ func (m *Manager) RestoreSnapshot(id string) error {
|
|||||||
}
|
}
|
||||||
name := c.VirshName()
|
name := c.VirshName()
|
||||||
instanceDir := m.instanceDir(name)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,8 +1163,8 @@ func (m *Manager) RestoreSnapshot(id string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
backupDir := filepath.Join(m.instancesDir(), fmt.Sprintf(".%s-restore-backup-%d", name, time.Now().UnixNano()))
|
backupDir := filepath.Join(instanceParent, fmt.Sprintf(".%s-restore-backup-%d", name, time.Now().UnixNano()))
|
||||||
if err := safePathUnder(backupDir, m.instancesDir()); err != nil {
|
if err := safePathUnder(backupDir, instanceParent); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.Rename(instanceDir, backupDir); err != nil && !os.IsNotExist(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))
|
return fmt.Errorf("virsh define failed after restore: %v, output: %s", err, string(output))
|
||||||
}
|
}
|
||||||
c.DiskImage = filepath.Join(instanceDir, "disk.qcow2")
|
c.DiskImage = filepath.Join(instanceDir, "disk.qcow2")
|
||||||
|
c.StoragePath = instanceDir
|
||||||
c.Status = "stopped"
|
c.Status = "stopped"
|
||||||
c.IP = ""
|
c.IP = ""
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
@@ -1244,7 +1319,33 @@ func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) tim
|
|||||||
}
|
}
|
||||||
|
|
||||||
func snapshotBaseDir() string {
|
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 {
|
func copyTree(src string, dst string) error {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Image struct {
|
type Image struct {
|
||||||
@@ -180,6 +183,9 @@ func FindImage(id string) *Image {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CacheDir() string {
|
func CacheDir() string {
|
||||||
|
if pool := config.PreferredStoragePoolForContent(config.StorageContentImages); pool != nil {
|
||||||
|
return filepath.Join(pool.Path, "images", "kvm")
|
||||||
|
}
|
||||||
return filepath.Join(BaseDir(), "images")
|
return filepath.Join(BaseDir(), "images")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +195,18 @@ func ImagePath(id string) string {
|
|||||||
if img != nil && img.Distro == "windows" {
|
if img != nil && img.Distro == "windows" {
|
||||||
ext = ".iso"
|
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".
|
// IsWindowsImage returns true if the image distro is "windows".
|
||||||
|
|||||||
+188
-45
@@ -18,6 +18,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
@@ -227,44 +228,53 @@ func NewManager() *Manager {
|
|||||||
|
|
||||||
// ContainerConfig defines container creation parameters
|
// ContainerConfig defines container creation parameters
|
||||||
type ContainerConfig struct {
|
type ContainerConfig struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Virtualization string `json:"virtualization,omitempty"`
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
VCPU float64 `json:"vcpu"`
|
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||||
CPUPercent int `json:"cpu_percent"`
|
VCPU float64 `json:"vcpu"`
|
||||||
RAMMB int `json:"ram_mb"`
|
CPUPercent int `json:"cpu_percent"`
|
||||||
DiskGB int `json:"disk_gb"`
|
RAMMB int `json:"ram_mb"`
|
||||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
DiskGB int `json:"disk_gb"`
|
||||||
NetworkDownMbps int `json:"network_down_mbps"`
|
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||||
NetworkUpMbps int `json:"network_up_mbps"`
|
NetworkDownMbps int `json:"network_down_mbps"`
|
||||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
NetworkUpMbps int `json:"network_up_mbps"`
|
||||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||||
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
|
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||||
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
|
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
|
||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
|
||||||
IOReadMBps int `json:"io_read_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
IOWriteMBps int `json:"io_write_mbps"`
|
IOReadMBps int `json:"io_read_mbps"`
|
||||||
ExtraPorts []int `json:"extra_ports"`
|
IOWriteMBps int `json:"io_write_mbps"`
|
||||||
PortMappingCount int `json:"port_mapping_count"`
|
ExtraPorts []int `json:"extra_ports"`
|
||||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
PortMappingCount int `json:"port_mapping_count"`
|
||||||
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||||
LANInterface string `json:"lan_interface,omitempty"`
|
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||||
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
LANInterface string `json:"lan_interface,omitempty"`
|
||||||
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||||
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||||
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
AssignIPv4 bool `json:"assign_ipv4"`
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
AssignIPv4 bool `json:"assign_ipv4"`
|
||||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||||
AssignIPv6 bool `json:"assign_ipv6"`
|
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||||
SSHPassword string `json:"ssh_password,omitempty"`
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
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() {
|
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.
|
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
|
||||||
func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||||
cfg.NormalizeResourceAliases()
|
cfg.NormalizeResourceAliases()
|
||||||
|
cfg.ReportProgress("preparing", "检查模板与创建参数")
|
||||||
tmpl := FindTemplate(cfg.TemplateID)
|
tmpl := FindTemplate(cfg.TemplateID)
|
||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
return fmt.Errorf("template not found: %s", cfg.TemplateID)
|
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",
|
fmt.Printf("Creating LXC container: %s (ID=%d, template: %s/%s/%s)\n",
|
||||||
lxcName, id, tmpl.Distro, tmpl.Release, tmpl.Arch)
|
lxcName, id, tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||||
|
|
||||||
|
cfg.ReportProgress("rootfs", "下载模板并创建基础文件系统")
|
||||||
args := []string{"-n", lxcName, "-t", "download", "--",
|
args := []string{"-n", lxcName, "-t", "download", "--",
|
||||||
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
|
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
|
||||||
if tmpl.Variant != "" {
|
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))
|
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 {
|
if err := m.applyDiskLimit(lxcName, cfg.DiskGB); err != nil {
|
||||||
_ = m.cleanupContainerStorage(lxcName)
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
cfg.ReportProgress("resources", "配置 CPU、内存与网络限制")
|
||||||
if cfg.WantsLANIPv4() {
|
if cfg.WantsLANIPv4() {
|
||||||
iface, err := m.applyLANIPv4Config(lxcName, cfg)
|
iface, err := m.applyLANIPv4Config(lxcName, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -399,6 +420,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.ReportProgress("addresses", "分配 IPv4、IPv6 与 NAT 端口")
|
||||||
publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = m.cleanupContainerStorage(lxcName)
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
@@ -472,6 +494,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
Name: cfg.Name,
|
Name: cfg.Name,
|
||||||
Virtualization: config.VirtualizationLXC,
|
Virtualization: config.VirtualizationLXC,
|
||||||
LXCName: lxcName,
|
LXCName: lxcName,
|
||||||
|
StoragePoolID: storagePoolID,
|
||||||
|
StoragePath: storagePath,
|
||||||
Template: cfg.TemplateID,
|
Template: cfg.TemplateID,
|
||||||
VCPU: cfg.VCPU,
|
VCPU: cfg.VCPU,
|
||||||
RAMMB: cfg.RAMMB,
|
RAMMB: cfg.RAMMB,
|
||||||
@@ -509,16 +533,19 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
ExpiresAt: cfg.ExpiresAt,
|
ExpiresAt: cfg.ExpiresAt,
|
||||||
}
|
}
|
||||||
container.NormalizeNetworkAssignments()
|
container.NormalizeNetworkAssignments()
|
||||||
|
cfg.ReportProgress("metadata", "保存容器配置")
|
||||||
config.AddContainer(container)
|
config.AddContainer(container)
|
||||||
|
|
||||||
// Pre-configure network and SSH in the rootfs before first boot.
|
// Pre-configure network and SSH in the rootfs before first boot.
|
||||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||||
|
cfg.ReportProgress("network", "写入容器网络配置")
|
||||||
m.preconfigureNetwork(rootfsPath, cfg)
|
m.preconfigureNetwork(rootfsPath, cfg)
|
||||||
if len(ipv6Assignments) > 0 {
|
if len(ipv6Assignments) > 0 {
|
||||||
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
|
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
|
||||||
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
|
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 {
|
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)
|
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 {
|
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||||
_ = m.cleanupContainerStorage(lxcName)
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
config.RemoveContainer(id)
|
config.RemoveContainer(id)
|
||||||
@@ -538,6 +566,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
|
|
||||||
// Set root password AFTER shiftRootfsForUnprivileged,
|
// Set root password AFTER shiftRootfsForUnprivileged,
|
||||||
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
|
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
|
||||||
|
cfg.ReportProgress("credentials", "设置容器登录凭据")
|
||||||
if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
|
if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
|
||||||
fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
|
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
|
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 {
|
func (m *Manager) ensureDiskImageMounted(lxcName string) error {
|
||||||
containerDir := filepath.Join(m.LxcPath, lxcName)
|
containerDir := filepath.Join(m.LxcPath, lxcName)
|
||||||
rootfsPath := filepath.Join(containerDir, "rootfs")
|
rootfsPath := filepath.Join(containerDir, "rootfs")
|
||||||
@@ -1183,15 +1284,30 @@ func diskImageMounted(lxcName, rootfsPath string) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
targetAbs, err := filepath.Abs(strings.TrimSpace(target))
|
return sameFilesystemPath(strings.TrimSpace(target), rootfsPath)
|
||||||
if err != nil {
|
}
|
||||||
return false
|
|
||||||
|
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)
|
|
||||||
if err != nil {
|
canonical := func(path string) (string, error) {
|
||||||
return false
|
absolute, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resolved, err := filepath.EvalSymlinks(absolute)
|
||||||
|
if err == nil {
|
||||||
|
absolute = resolved
|
||||||
|
}
|
||||||
|
return filepath.Clean(absolute), nil
|
||||||
}
|
}
|
||||||
return targetAbs == rootfsAbs
|
leftPath, leftErr := canonical(left)
|
||||||
|
rightPath, rightErr := canonical(right)
|
||||||
|
return leftErr == nil && rightErr == nil && leftPath == rightPath
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyXFSProjectQuota(rootfsPath, lxcName string, diskGB int) error {
|
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) {
|
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
|
||||||
return nil
|
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-stop", "-n", lxcName, "-k").Run()
|
||||||
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
|
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
|
||||||
m.detachContainerMounts(cleanPath)
|
m.detachContainerMounts(cleanPath)
|
||||||
@@ -2756,9 +2883,25 @@ func (m *Manager) cleanupContainerStorage(lxcName string) error {
|
|||||||
if err := os.RemoveAll(cleanPath); err != nil {
|
if err := os.RemoveAll(cleanPath); err != nil {
|
||||||
return fmt.Errorf("failed to remove container directory %s: %v", cleanPath, err)
|
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
|
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) {
|
func (m *Manager) detachContainerMounts(containerDir string) {
|
||||||
out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", containerDir).Output()
|
out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", containerDir).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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) {
|
func TestAppendMissingSeccompRulesAddsFutexMitigationOnce(t *testing.T) {
|
||||||
base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n"
|
base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n"
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
var snapshotMu sync.Mutex
|
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()
|
snapshotMu.Lock()
|
||||||
defer snapshotMu.Unlock()
|
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 {
|
if _, err := os.Stat(containerDir); err != nil {
|
||||||
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
|
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()
|
now := time.Now()
|
||||||
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
||||||
// Use container ID instead of lxcName to avoid collision when containers are recreated
|
// Use container ID instead of lxcName to avoid collision when containers are recreated
|
||||||
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID)
|
baseDir := filepath.Join(pool.Path, "snapshots")
|
||||||
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
|
snapshotDir := filepath.Join(baseDir, strconv.Itoa(id), snapshotID)
|
||||||
|
if err := safePathUnder(snapshotDir, baseDir); err != nil {
|
||||||
return config.Snapshot{}, err
|
return config.Snapshot{}, err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
|
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 {
|
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
|
||||||
if snapshot.Path != "" {
|
if snapshot.Path != "" {
|
||||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
if err := safeSnapshotPath(snapshot.Path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.RemoveAll(snapshot.Path); err != nil {
|
if err := os.RemoveAll(snapshot.Path); err != nil {
|
||||||
@@ -122,7 +131,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
|
|||||||
if snapshot.Path == "" {
|
if snapshot.Path == "" {
|
||||||
return fmt.Errorf("snapshot path is empty")
|
return fmt.Errorf("snapshot path is empty")
|
||||||
}
|
}
|
||||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
if err := safeSnapshotPath(snapshot.Path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(snapshot.Path); err != nil {
|
if _, err := os.Stat(snapshot.Path); err != nil {
|
||||||
@@ -295,7 +304,33 @@ func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerD
|
|||||||
}
|
}
|
||||||
|
|
||||||
func snapshotBaseDir() string {
|
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 {
|
func copyTree(src string, dst string) error {
|
||||||
@@ -313,6 +348,9 @@ func copyTree(src string, dst string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func dirSizeBytes(path string) int64 {
|
func dirSizeBytes(path string) int64 {
|
||||||
|
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
||||||
|
path = resolved
|
||||||
|
}
|
||||||
out, err := exec.Command("du", "-s", "-B1", path).Output()
|
out, err := exec.Command("du", "-s", "-B1", path).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -66,9 +66,11 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||||
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
|
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
|
||||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
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/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.SubUserMiddleware(api.HandleTasks))))
|
||||||
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
|
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-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
|
||||||
mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction)))
|
mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction)))
|
||||||
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
|
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/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/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
|
||||||
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
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/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.SubUserMiddleware(api.HandleTasks))))
|
||||||
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
|
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-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
|
||||||
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
|
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
|
||||||
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
|
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func main() {
|
|||||||
installShutdownStateCapture()
|
installShutdownStateCapture()
|
||||||
|
|
||||||
// Restore persisted state
|
// Restore persisted state
|
||||||
|
api.ConfigureTaskQueue(cfg.TaskConcurrency)
|
||||||
api.RestoreTasks()
|
api.RestoreTasks()
|
||||||
api.RestoreLoginLogs()
|
api.RestoreLoginLogs()
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Settings from './pages/Settings'
|
|||||||
import ImageManagement from './pages/ImageManagement'
|
import ImageManagement from './pages/ImageManagement'
|
||||||
import Snapshots from './pages/Snapshots'
|
import Snapshots from './pages/Snapshots'
|
||||||
import Routing from './pages/Routing'
|
import Routing from './pages/Routing'
|
||||||
|
import Storage from './pages/Storage'
|
||||||
import SubUserManagement from './pages/SubUserManagement'
|
import SubUserManagement from './pages/SubUserManagement'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ function App() {
|
|||||||
<Route path="security" element={<Security />} />
|
<Route path="security" element={<Security />} />
|
||||||
<Route path="snapshots" element={<Snapshots />} />
|
<Route path="snapshots" element={<Snapshots />} />
|
||||||
<Route path="routing" element={<Routing />} />
|
<Route path="routing" element={<Routing />} />
|
||||||
|
<Route path="storage" element={<Storage />} />
|
||||||
<Route path="audit-logs" element={<AuditLogs />} />
|
<Route path="audit-logs" element={<AuditLogs />} />
|
||||||
<Route path="api-integration" element={<ApiIntegration />} />
|
<Route path="api-integration" element={<ApiIntegration />} />
|
||||||
<Route path="host-report" element={<HostReport />} />
|
<Route path="host-report" element={<HostReport />} />
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
import { useDialog } from './Dialog'
|
||||||
|
|
||||||
export default function BrowserDialogTranslator() {
|
export default function BrowserDialogTranslator() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const { alert: showAlert } = useDialog()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const originalAlert = window.alert
|
const originalAlert = window.alert
|
||||||
const originalConfirm = window.confirm
|
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 ?? '')))
|
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
|
||||||
return () => {
|
return () => {
|
||||||
window.alert = originalAlert
|
window.alert = originalAlert
|
||||||
window.confirm = originalConfirm
|
window.confirm = originalConfirm
|
||||||
}
|
}
|
||||||
}, [t])
|
}, [showAlert, t])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { CalendarClock, RefreshCw, X } from 'lucide-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 { useDialog } from './Dialog'
|
||||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||||
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
|
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
|
||||||
@@ -16,6 +17,7 @@ const defaultForm: CreateContainerRequest = {
|
|||||||
name: '',
|
name: '',
|
||||||
virtualization: 'lxc',
|
virtualization: 'lxc',
|
||||||
template_id: '',
|
template_id: '',
|
||||||
|
storage_pool_id: '',
|
||||||
vcpu: 1,
|
vcpu: 1,
|
||||||
cpu_percent: 100,
|
cpu_percent: 100,
|
||||||
ram_mb: 512,
|
ram_mb: 512,
|
||||||
@@ -54,6 +56,7 @@ const defaultForm: CreateContainerRequest = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
|
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
const networkText = createNetworkText[language]
|
const networkText = createNetworkText[language]
|
||||||
@@ -63,6 +66,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
|
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
|
||||||
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
||||||
const [hostReport, setHostReport] = useState<HostProbeReport | 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 [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
|
||||||
const [nameError, setNameError] = useState('')
|
const [nameError, setNameError] = useState('')
|
||||||
|
|
||||||
@@ -107,8 +112,26 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
getHostReport()
|
getHostReport()
|
||||||
.then((res) => setHostReport(res.data.data || null))
|
.then((res) => setHostReport(res.data.data || null))
|
||||||
.catch(() => setHostReport(null))
|
.catch(() => setHostReport(null))
|
||||||
|
|
||||||
}, [isOpen, form.virtualization])
|
}, [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 ipv6Available = !!ipv6Status?.available
|
||||||
const ipv6Prefixes = ipv6Status?.prefixes || []
|
const ipv6Prefixes = ipv6Status?.prefixes || []
|
||||||
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
|
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 maxVCPU = hostInfo?.cpu.cores || 64
|
||||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||||
const kvmAvailable = !!hostInfo?.runtime?.kvm_available
|
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(() => {
|
useEffect(() => {
|
||||||
if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') {
|
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)
|
const authError = validateSSHAuthInputs(form)
|
||||||
if (authError) {
|
if (authError) {
|
||||||
dialog.alert('登录方式有误', authError)
|
dialog.alert('登录方式有误', authError)
|
||||||
@@ -273,7 +306,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="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'}`}
|
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 容器
|
LXC 容器
|
||||||
@@ -284,7 +317,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
|
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (kvmAvailable) {
|
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'}`}
|
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>
|
||||||
|
|
||||||
|
<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 && (
|
{templates.length > 0 && (
|
||||||
<Field label="子用户可用镜像">
|
<Field label="子用户可用镜像">
|
||||||
<div className="rounded-md border border-gray-200 bg-gray-50 p-3">
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
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"
|
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 ? '创建中...' : '创建容器'}
|
{loading ? '创建中...' : '创建容器'}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
|
import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'
|
||||||
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
|
import { AlertTriangle, CheckCircle2, CircleAlert, Info, X } from 'lucide-react'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
|
||||||
type DialogType = 'confirm' | 'alert'
|
|
||||||
|
|
||||||
interface DialogState {
|
interface DialogState {
|
||||||
open: boolean
|
open: boolean
|
||||||
type: DialogType
|
|
||||||
title: string
|
title: string
|
||||||
message: string
|
message: string
|
||||||
resolve?: (value: boolean) => void
|
resolve?: (value: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToastTone = 'success' | 'error' | 'warning' | 'info'
|
||||||
|
|
||||||
|
interface ToastState {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
tone: ToastTone
|
||||||
|
}
|
||||||
|
|
||||||
interface DialogContextType {
|
interface DialogContextType {
|
||||||
confirm: (title: string, message: string) => Promise<boolean>
|
confirm: (title: string, message: string) => Promise<boolean>
|
||||||
alert: (title: string, message: string) => Promise<void>
|
alert: (title: string, message: string) => Promise<void>
|
||||||
@@ -19,67 +25,107 @@ interface DialogContextType {
|
|||||||
|
|
||||||
const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
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 }) {
|
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 { t } = useLanguage()
|
||||||
|
|
||||||
const confirm = useCallback((title: string, message: string) => {
|
const confirm = useCallback((title: string, message: string) => {
|
||||||
return new Promise<boolean>((resolve) => {
|
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) => {
|
const alert = useCallback((title: string, message: string) => {
|
||||||
return new Promise<void>((resolve) => {
|
const id = ++toastID.current
|
||||||
setDialog({ open: true, type: 'alert', title, message, resolve: () => resolve() })
|
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) => {
|
const close = (result: boolean) => {
|
||||||
dialog.resolve?.(result)
|
dialog.resolve?.(result)
|
||||||
setDialog({ open: false, type: 'alert', title: '', message: '' })
|
setDialog({ open: false, title: '', message: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContext.Provider value={{ confirm, alert }}>
|
<DialogContext.Provider value={{ confirm, alert }}>
|
||||||
{children}
|
{children}
|
||||||
{dialog.open && (
|
<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">
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
|
{toasts.map((toast) => {
|
||||||
<div className="bg-white rounded-lg shadow-xl border border-gray-200 w-full max-w-sm overflow-hidden">
|
const style = toastStyles[toast.tone]
|
||||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100">
|
const ToastIcon = style.icon
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
return (
|
||||||
dialog.type === 'confirm' ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-600'
|
<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">
|
||||||
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
|
<div className={`mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full ${style.iconClass}`}>
|
||||||
</div>
|
<ToastIcon className="h-4 w-4" />
|
||||||
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
|
</div>
|
||||||
{dialog.type === 'alert' && (
|
<div className="min-w-0 flex-1">
|
||||||
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">{t(toast.title)}</div>
|
||||||
<X className="w-4 h-4" />
|
<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>
|
</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>
|
||||||
<div className="px-5 py-4">
|
<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>
|
||||||
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
|
<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">
|
||||||
{dialog.type === 'confirm' && (
|
<button
|
||||||
<button
|
onClick={() => close(false)}
|
||||||
onClick={() => close(false)}
|
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"
|
||||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
|
>
|
||||||
>
|
{t('取消')}
|
||||||
{t('取消')}
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => close(true)}
|
onClick={() => close(true)}
|
||||||
className={`px-4 py-2 text-sm rounded-md transition-colors ${
|
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'
|
|
||||||
? 'bg-black text-white hover:bg-gray-800'
|
|
||||||
: 'bg-black text-white hover:bg-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{dialog.type === 'confirm' ? t('确认') : t('确定')}
|
{t('确认')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Code2,
|
Code2,
|
||||||
Cpu,
|
Cpu,
|
||||||
Camera,
|
Camera,
|
||||||
|
HardDrive,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -83,6 +84,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
|
|
||||||
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
|
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
|
||||||
const isRoutingPage = location.pathname.startsWith('/routing')
|
const isRoutingPage = location.pathname.startsWith('/routing')
|
||||||
|
const isStoragePage = location.pathname.startsWith('/storage')
|
||||||
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
||||||
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
||||||
const isHostReportPage = location.pathname.startsWith('/host-report')
|
const isHostReportPage = location.pathname.startsWith('/host-report')
|
||||||
@@ -201,6 +203,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
{!collapsed && <span>路由管理</span>}
|
{!collapsed && <span>路由管理</span>}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => navigate('/audit-logs')}
|
onClick={() => navigate('/audit-logs')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ body {
|
|||||||
/* Shadow */
|
/* Shadow */
|
||||||
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
|
.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-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 */
|
/* bg-black buttons in dark mode -> light */
|
||||||
.dark .bg-black { background-color: #f9fafb !important; }
|
.dark .bg-black { background-color: #f9fafb !important; }
|
||||||
@@ -121,6 +123,7 @@ body {
|
|||||||
.dark .bg-amber-50 { background-color: #451a03 !important; }
|
.dark .bg-amber-50 { background-color: #451a03 !important; }
|
||||||
.dark .bg-emerald-50 { background-color: #064e3b !important; }
|
.dark .bg-emerald-50 { background-color: #064e3b !important; }
|
||||||
.dark .bg-amber-100 { background-color: #78350f !important; }
|
.dark .bg-amber-100 { background-color: #78350f !important; }
|
||||||
|
.dark .bg-indigo-50 { background-color: #1e1b4b !important; }
|
||||||
|
|
||||||
/* Status badge text */
|
/* Status badge text */
|
||||||
.dark .text-green-700 { color: #6ee7b7 !important; }
|
.dark .text-green-700 { color: #6ee7b7 !important; }
|
||||||
@@ -129,6 +132,14 @@ body {
|
|||||||
.dark .text-amber-600 { color: #fcd34d !important; }
|
.dark .text-amber-600 { color: #fcd34d !important; }
|
||||||
.dark .text-amber-700 { color: #fcd34d !important; }
|
.dark .text-amber-700 { color: #fcd34d !important; }
|
||||||
.dark .text-emerald-700 { color: #6ee7b7 !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 */
|
/* Focus ring */
|
||||||
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
|
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
|
||||||
@@ -137,6 +148,37 @@ body {
|
|||||||
/* Accent */
|
/* Accent */
|
||||||
.dark .accent-black { accent-color: #f9fafb !important; }
|
.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 */
|
/* Spinner */
|
||||||
.dark .border-black { border-color: #f9fafb !important; }
|
.dark .border-black { border-color: #f9fafb !important; }
|
||||||
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
|
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
getContainerSnapshots,
|
getContainerSnapshots,
|
||||||
getContainerUsage,
|
getContainerUsage,
|
||||||
getHostInfo,
|
getHostInfo,
|
||||||
|
getStorageInfo,
|
||||||
getTrafficInfo,
|
getTrafficInfo,
|
||||||
HostInfo,
|
HostInfo,
|
||||||
TrafficInfo,
|
TrafficInfo,
|
||||||
@@ -61,6 +62,7 @@ import {
|
|||||||
stopContainer,
|
stopContainer,
|
||||||
Snapshot,
|
Snapshot,
|
||||||
SnapshotSchedule,
|
SnapshotSchedule,
|
||||||
|
StorageInfo,
|
||||||
Template,
|
Template,
|
||||||
updateContainerExpiry,
|
updateContainerExpiry,
|
||||||
updateFirewall,
|
updateFirewall,
|
||||||
@@ -75,6 +77,7 @@ import {
|
|||||||
} from '../services/api'
|
} from '../services/api'
|
||||||
import { useDialog } from '../components/Dialog'
|
import { useDialog } from '../components/Dialog'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import WebSSHViewer from '../components/WebSSHViewer'
|
import WebSSHViewer from '../components/WebSSHViewer'
|
||||||
import WebVNCViewer from '../components/WebVNCViewer'
|
import WebVNCViewer from '../components/WebVNCViewer'
|
||||||
import { RingStat } from '../components/RingStats'
|
import { RingStat } from '../components/RingStats'
|
||||||
@@ -127,6 +130,7 @@ export default function ContainerDetail() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { isSubUser } = useAuth()
|
const { isSubUser } = useAuth()
|
||||||
|
const { t } = useLanguage()
|
||||||
const [container, setContainer] = useState<Container | null>(null)
|
const [container, setContainer] = useState<Container | null>(null)
|
||||||
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
||||||
const [usage, setUsage] = useState<ContainerUsage | null>(null)
|
const [usage, setUsage] = useState<ContainerUsage | null>(null)
|
||||||
@@ -182,6 +186,9 @@ export default function ContainerDetail() {
|
|||||||
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
|
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
|
||||||
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
|
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
|
||||||
const [snapshotBusy, setSnapshotBusy] = useState('')
|
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 [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
|
||||||
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
||||||
const [showFirewall, setShowFirewall] = useState(false)
|
const [showFirewall, setShowFirewall] = useState(false)
|
||||||
@@ -224,6 +231,23 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
}, [containerIdentifier, container?.snapshot_limit])
|
}, [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 () => {
|
const fetchMetricHistory = useCallback(async () => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
try {
|
try {
|
||||||
@@ -297,8 +321,11 @@ export default function ContainerDetail() {
|
|||||||
}, [fetchMetricHistory])
|
}, [fetchMetricHistory])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showSnapshots) fetchSnapshots()
|
if (showSnapshots) {
|
||||||
}, [showSnapshots, fetchSnapshots])
|
fetchSnapshots()
|
||||||
|
fetchStorage()
|
||||||
|
}
|
||||||
|
}, [showSnapshots, fetchSnapshots, fetchStorage])
|
||||||
|
|
||||||
// Poll task status for this container
|
// Poll task status for this container
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -774,6 +801,10 @@ export default function ContainerDetail() {
|
|||||||
const handleCreateSnapshot = async () => {
|
const handleCreateSnapshot = async () => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
if (!(await ensureSubUserCanOperate())) return
|
if (!(await ensureSubUserCanOperate())) return
|
||||||
|
if (!snapshotStorageReady) {
|
||||||
|
await dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isSubUser && snapshots.length >= snapshotQuota) {
|
if (isSubUser && snapshots.length >= snapshotQuota) {
|
||||||
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
||||||
return
|
return
|
||||||
@@ -787,7 +818,7 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
setSnapshotBusy('create')
|
setSnapshotBusy('create')
|
||||||
try {
|
try {
|
||||||
await createContainerSnapshot(containerIdentifier)
|
await createContainerSnapshot(containerIdentifier, { storage_pool_id: snapshotStoragePoolID || undefined })
|
||||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { message?: string } } }
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
@@ -799,6 +830,10 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
const openSnapshotSchedule = () => {
|
const openSnapshotSchedule = () => {
|
||||||
if (isSubUser && container?.policy_blocked) return
|
if (isSubUser && container?.policy_blocked) return
|
||||||
|
if (!snapshotStorageReady) {
|
||||||
|
dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
|
||||||
|
return
|
||||||
|
}
|
||||||
setSnapshotScheduleDraft({
|
setSnapshotScheduleDraft({
|
||||||
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
||||||
time: snapshotSchedule?.time || '03:00',
|
time: snapshotSchedule?.time || '03:00',
|
||||||
@@ -924,6 +959,10 @@ export default function ContainerDetail() {
|
|||||||
const hasIndependentIPv4 = assignedIPv4List.length > 0
|
const hasIndependentIPv4 = assignedIPv4List.length > 0
|
||||||
const hasIndependentIPv6 = ipv6List.length > 0
|
const hasIndependentIPv6 = ipv6List.length > 0
|
||||||
const defaultConnPort = isWindows ? 3389 : 22
|
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 publicEndpoint = '-'
|
||||||
let sshCommand = ''
|
let sshCommand = ''
|
||||||
@@ -1231,8 +1270,8 @@ export default function ContainerDetail() {
|
|||||||
<PlainRow label="vCPU" value={`${container.vcpu} 核`} />
|
<PlainRow label="vCPU" value={`${container.vcpu} 核`} />
|
||||||
<PlainRow label="内存" value={`${container.ram_mb} MB`} />
|
<PlainRow label="内存" value={`${container.ram_mb} MB`} />
|
||||||
<PlainRow label="磁盘" value={`${container.disk_gb} GB`} />
|
<PlainRow label="磁盘" value={`${container.disk_gb} GB`} />
|
||||||
<PlainRow label="网络速率" value={formatDirectionalLimit('下行', networkDownLimit, '上行', networkUpLimit, 'Mbps')} />
|
<PlainRow label="网络速率" value={formatDirectionalLimit(t('下行'), networkDownLimit, t('上行'), networkUpLimit, 'Mbps')} />
|
||||||
<PlainRow label="IO 速度" value={formatDirectionalLimit('读取', ioReadLimit, '写入', ioWriteLimit, 'MB/s')} />
|
<PlainRow label="IO 速度" value={formatDirectionalLimit(t('读取'), ioReadLimit, t('写入'), ioWriteLimit, 'MB/s')} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel title="实时状态">
|
<Panel title="实时状态">
|
||||||
@@ -1492,7 +1531,7 @@ export default function ContainerDetail() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={openSnapshotSchedule}
|
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 ${
|
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs ${
|
||||||
snapshotSchedule?.enabled
|
snapshotSchedule?.enabled
|
||||||
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
|
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
|
||||||
@@ -1504,7 +1543,7 @@ export default function ContainerDetail() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateSnapshot}
|
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"
|
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" />
|
<Camera className="w-3.5 h-3.5" />
|
||||||
@@ -1514,6 +1553,20 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<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 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>
|
<div>
|
||||||
快照数量:
|
快照数量:
|
||||||
@@ -1549,6 +1602,26 @@ export default function ContainerDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{editingSnapshotQuota && !isSubUser && (
|
||||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||||
<Field label="子用户每台容器快照上限">
|
<Field label="子用户每台容器快照上限">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import CreateContainerModal from '../components/CreateContainerModal'
|
import CreateContainerModal from '../components/CreateContainerModal'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
CreateContainerRequest,
|
CreateContainerRequest,
|
||||||
@@ -391,7 +392,7 @@ export default function Containers() {
|
|||||||
{pageContainers.map((container) => {
|
{pageContainers.map((container) => {
|
||||||
const isRunning = container.status === 'running'
|
const isRunning = container.status === 'running'
|
||||||
const isInitializing = container.status === 'initializing'
|
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 isPlaceholder = !!container.isPlaceholder
|
||||||
const isPolicyBlocked = !!container.policy_blocked
|
const isPolicyBlocked = !!container.policy_blocked
|
||||||
const usage = usageByName[container.name]
|
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 }) {
|
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"
|
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
|
||||||
if (policyBlocked) {
|
if (policyBlocked) {
|
||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||||
策略封禁
|
{t('策略封禁')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -595,7 +597,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
|||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||||
初始化失败
|
{t('初始化失败')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -604,16 +606,17 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
|||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-emerald-50 text-emerald-700`}>
|
<span className={`${baseClass} bg-emerald-50 text-emerald-700`}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||||
初始化完成
|
{t('初始化完成')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task?.type === 'create' && task.status === 'running') {
|
if (task?.type === 'create' && task.status === 'running') {
|
||||||
|
const detail = t(task.stage_detail || '正在初始化')
|
||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
<span className={`${baseClass} max-w-[210px] bg-amber-50 text-amber-700`} title={`${t('正在初始化')}: ${detail}`}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
<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>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -622,7 +625,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
|||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-gray-100 text-gray-500`}>
|
<span className={`${baseClass} bg-gray-100 text-gray-500`}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
||||||
排队等待
|
{t('排队等待')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -634,7 +637,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
|||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
<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="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||||
{taskLabels[task.type] || '处理中'}
|
{t(taskLabels[task.type] || '处理中')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -643,7 +646,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
|||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
<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="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||||
正在初始化
|
{t('正在初始化')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -651,7 +654,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
|||||||
return (
|
return (
|
||||||
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
|
<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>
|
<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>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -788,7 +791,7 @@ type ContainerFilters = {
|
|||||||
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
|
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
|
||||||
const keyword = filters.search.trim().toLowerCase()
|
const keyword = filters.search.trim().toLowerCase()
|
||||||
return containers.filter((container) => {
|
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) {
|
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -865,6 +868,7 @@ function getContainerStatusFilterValue(container: DisplayContainer, task?: Task)
|
|||||||
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
|
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
|
||||||
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
|
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
|
||||||
if (task.type === 'create' && task.status === 'done') return '初始化完成'
|
if (task.type === 'create' && task.status === 'done') return '初始化完成'
|
||||||
|
if (task.type === 'create' && task.status === 'running') return task.stage_detail || '正在初始化'
|
||||||
return actionLabels[task.type] || '处理中...'
|
return actionLabels[task.type] || '处理中...'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,13 +877,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
|||||||
onRefresh: () => void | Promise<void>
|
onRefresh: () => void | Promise<void>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<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 className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-black">任务队列</h2>
|
<h2 className="text-base font-semibold text-black">{t('任务队列')}</h2>
|
||||||
<p className="mt-0.5 text-xs text-gray-500">共 {tasks.length} 个任务</p>
|
<p className="mt-0.5 text-xs text-gray-500">{t(`共 ${tasks.length} 个任务`)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<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"
|
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" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
刷新
|
{t('刷新')}
|
||||||
</button>
|
</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" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tasks.length === 0 ? (
|
{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">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
|
<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">{t('状态')}</th>
|
||||||
<th className="whitespace-nowrap 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">容器</th>
|
<th className="whitespace-nowrap px-4 py-2.5">{t('容器')}</th>
|
||||||
<th className="whitespace-nowrap px-4 py-2.5">创建时间</th>
|
<th className="whitespace-nowrap px-4 py-2.5">{t('当前阶段')}</th>
|
||||||
<th className="px-4 py-2.5">错误</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>
|
<th className="whitespace-nowrap px-4 py-2.5 w-10"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -915,11 +921,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
|||||||
<tr key={task.id} className="hover:bg-gray-50">
|
<tr key={task.id} className="hover:bg-gray-50">
|
||||||
<td className="whitespace-nowrap px-4 py-2.5">
|
<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)}`}>
|
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${taskStatusClass(task.status)}`}>
|
||||||
{taskStatusLabel(task.status)}
|
{t(taskStatusLabel(task.status))}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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="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="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="min-w-[260px] px-4 py-2.5 text-gray-600">{task.error || '-'}</td>
|
||||||
<td className="whitespace-nowrap px-2 py-2.5">
|
<td className="whitespace-nowrap px-2 py-2.5">
|
||||||
@@ -932,7 +941,7 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}}
|
}}
|
||||||
className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-red-600 transition-colors"
|
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" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -11,15 +12,18 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} 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'
|
import { useDialog } from '../components/Dialog'
|
||||||
|
|
||||||
export default function ImageManagement() {
|
export default function ImageManagement() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [images, setImages] = useState<ImageInfo[]>([])
|
const [images, setImages] = useState<ImageInfo[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
|
||||||
|
const [storageLoading, setStorageLoading] = useState(true)
|
||||||
|
|
||||||
const fetchImages = useCallback(async () => {
|
const fetchImages = useCallback(async () => {
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
fetchImages()
|
fetchImages()
|
||||||
}, [fetchImages])
|
fetchStorage()
|
||||||
|
}, [fetchImages, fetchStorage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasDownloads = images.some((img) => img.downloading)
|
const hasDownloads = images.some((img) => img.downloading)
|
||||||
@@ -101,6 +118,9 @@ export default function ImageManagement() {
|
|||||||
const downloadedCount = images.filter((img) => img.downloaded).length
|
const downloadedCount = images.filter((img) => img.downloaded).length
|
||||||
const lxcImages = images.filter((img) => img.type === 'lxc')
|
const lxcImages = images.filter((img) => img.type === 'lxc')
|
||||||
const kvmImages = images.filter((img) => img.type === 'kvm')
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -121,7 +141,7 @@ export default function ImageManagement() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
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" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
@@ -136,6 +156,25 @@ export default function ImageManagement() {
|
|||||||
</div>
|
</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
|
<ImageTable
|
||||||
title="LXC 容器镜像"
|
title="LXC 容器镜像"
|
||||||
images={lxcImages}
|
images={lxcImages}
|
||||||
@@ -146,6 +185,8 @@ export default function ImageManagement() {
|
|||||||
onCancelDownload={handleCancelDownload}
|
onCancelDownload={handleCancelDownload}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
|
storageReady={imageStorageReady}
|
||||||
|
storageLoading={storageLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{kvmImages.length > 0 && (
|
{kvmImages.length > 0 && (
|
||||||
@@ -159,6 +200,8 @@ export default function ImageManagement() {
|
|||||||
onCancelDownload={handleCancelDownload}
|
onCancelDownload={handleCancelDownload}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
|
storageReady={imageStorageReady}
|
||||||
|
storageLoading={storageLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -175,6 +218,8 @@ function ImageTable({
|
|||||||
onCancelDownload,
|
onCancelDownload,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
storageReady,
|
||||||
|
storageLoading,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
images: ImageInfo[]
|
images: ImageInfo[]
|
||||||
@@ -185,6 +230,8 @@ function ImageTable({
|
|||||||
onCancelDownload: (id: string) => void
|
onCancelDownload: (id: string) => void
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: string) => void
|
||||||
onToggle: (id: string, enabled: boolean) => void
|
onToggle: (id: string, enabled: boolean) => void
|
||||||
|
storageReady: boolean
|
||||||
|
storageLoading: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -253,7 +300,8 @@ function ImageTable({
|
|||||||
{!img.downloaded && !img.downloading && (
|
{!img.downloaded && !img.downloading && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onDownload(img.id)}
|
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"
|
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 ? (
|
{isBusy ? (
|
||||||
@@ -281,7 +329,8 @@ function ImageTable({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggle(img.id, img.enabled)}
|
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 ${
|
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
|
img.enabled
|
||||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100'
|
? '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 }) {
|
function StatusBadge({ img }: { img: ImageInfo }) {
|
||||||
if (img.downloading) {
|
if (img.downloading) {
|
||||||
const progress = Math.max(0, Math.min(100, img.progress || 0))
|
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 (
|
return (
|
||||||
<div className="inline-flex flex-col gap-1">
|
<div className="inline-flex flex-col gap-1">
|
||||||
<span
|
<span
|
||||||
@@ -329,7 +378,10 @@ function StatusBadge({ img }: { img: ImageInfo }) {
|
|||||||
</span>
|
</span>
|
||||||
{showProgress && (
|
{showProgress && (
|
||||||
<span className="block h-1 w-24 overflow-hidden rounded-full bg-amber-100">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -375,6 +427,7 @@ function downloadStatusLabel(img: ImageInfo) {
|
|||||||
if (img.stage === 'converting') return '转换中'
|
if (img.stage === 'converting') return '转换中'
|
||||||
if (img.stage === 'lxc-create') return '下载中'
|
if (img.stage === 'lxc-create') return '下载中'
|
||||||
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
|
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
|
||||||
|
if (img.downloaded_bytes > 0) return `下载中 · ${formatSize(img.downloaded_bytes)}`
|
||||||
return '下载中'
|
return '下载中'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+233
-73
@@ -1,23 +1,38 @@
|
|||||||
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
|
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 {
|
import {
|
||||||
changePassword,
|
changePassword,
|
||||||
changeUsername,
|
changeUsername,
|
||||||
getLoginLogs,
|
getLoginLogs,
|
||||||
getSSLSettings,
|
getSSLSettings,
|
||||||
|
getTaskQueueSettings,
|
||||||
getWebSSHOriginSettings,
|
getWebSSHOriginSettings,
|
||||||
LoginLog,
|
LoginLog,
|
||||||
SSLSettings,
|
SSLSettings,
|
||||||
|
TaskQueueSettings,
|
||||||
|
updateTaskQueueSettings,
|
||||||
updateSSLSettings,
|
updateSSLSettings,
|
||||||
updateWebSSHOriginSettings,
|
updateWebSSHOriginSettings,
|
||||||
WebSSHOriginSettings,
|
WebSSHOriginSettings,
|
||||||
} from '../services/api'
|
} from '../services/api'
|
||||||
import { useDialog } from '../components/Dialog'
|
import { useDialog } from '../components/Dialog'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
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() {
|
export default function Settings() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { username } = useAuth()
|
const { username } = useAuth()
|
||||||
|
const { t } = useLanguage()
|
||||||
const [logs, setLogs] = useState<LoginLog[]>([])
|
const [logs, setLogs] = useState<LoginLog[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [logPage, setLogPage] = useState(1)
|
const [logPage, setLogPage] = useState(1)
|
||||||
@@ -39,6 +54,10 @@ export default function Settings() {
|
|||||||
const [webSSHOrigins, setWebSSHOrigins] = useState<WebSSHOriginSettings | null>(null)
|
const [webSSHOrigins, setWebSSHOrigins] = useState<WebSSHOriginSettings | null>(null)
|
||||||
const [webSSHOriginsText, setWebSSHOriginsText] = useState('')
|
const [webSSHOriginsText, setWebSSHOriginsText] = useState('')
|
||||||
const [savingWebSSHOrigins, setSavingWebSSHOrigins] = useState(false)
|
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 () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
fetchLogs()
|
fetchLogs()
|
||||||
fetchSSL()
|
fetchSSL()
|
||||||
fetchWebSSHOrigins()
|
fetchWebSSHOrigins()
|
||||||
const timer = setInterval(fetchLogs, 15000)
|
fetchTaskQueue()
|
||||||
return () => clearInterval(timer)
|
const logTimer = setInterval(fetchLogs, 15000)
|
||||||
}, [fetchLogs, fetchSSL, fetchWebSSHOrigins])
|
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']) => {
|
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
|
||||||
setSSLMode(mode)
|
setSSLMode(mode)
|
||||||
@@ -190,72 +245,173 @@ export default function Settings() {
|
|||||||
const totalPages = Math.ceil(logs.length / pageSize)
|
const totalPages = Math.ceil(logs.length / pageSize)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-black">面板设置</h1>
|
<h1 className="text-2xl font-bold text-black dark:text-white">面板设置</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">账号、安全证书与登录日志</p>
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">任务队列、账号、安全证书与访问记录</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
<div className="grid items-start gap-4 lg:grid-cols-[210px_minmax(0,1fr)]">
|
||||||
<div className="space-y-6">
|
<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">
|
||||||
<SSLCard
|
<nav className="flex min-w-max gap-1 lg:min-w-0 lg:flex-col" aria-label="设置分类">
|
||||||
ssl={ssl}
|
{settingsSections.map((section) => {
|
||||||
sslEnabled={sslEnabled}
|
const Icon = section.icon
|
||||||
sslMode={sslMode}
|
const active = activeSection === section.id
|
||||||
sslTarget={sslTarget}
|
return (
|
||||||
sslEmail={sslEmail}
|
<button
|
||||||
certPEM={certPEM}
|
key={section.id}
|
||||||
keyPEM={keyPEM}
|
type="button"
|
||||||
applyNow={applyNow}
|
onClick={() => setActiveSection(section.id)}
|
||||||
savingSSL={savingSSL}
|
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'}`}
|
||||||
onRefresh={fetchSSL}
|
>
|
||||||
onEnabledChange={setSSLEnabled}
|
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||||
onModeChange={handleSSLModeChange}
|
<span>{t(section.label)}</span>
|
||||||
onTargetChange={setSSLTarget}
|
</button>
|
||||||
onEmailChange={setSSLEmail}
|
)
|
||||||
onCertChange={setCertPEM}
|
})}
|
||||||
onKeyChange={setKeyPEM}
|
</nav>
|
||||||
onApplyNowChange={setApplyNow}
|
</aside>
|
||||||
onSave={handleSaveSSL}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WebSSHOriginCard
|
<section className="min-w-0">
|
||||||
settings={webSSHOrigins}
|
{activeSection === 'tasks' && (
|
||||||
originsText={webSSHOriginsText}
|
<TaskQueueCard
|
||||||
saving={savingWebSSHOrigins}
|
settings={taskQueue}
|
||||||
onOriginsTextChange={setWebSSHOriginsText}
|
concurrency={taskConcurrency}
|
||||||
onRefresh={fetchWebSSHOrigins}
|
saving={savingTaskQueue}
|
||||||
onSave={handleSaveWebSSHOrigins}
|
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}
|
||||||
|
sslMode={sslMode}
|
||||||
|
sslTarget={sslTarget}
|
||||||
|
sslEmail={sslEmail}
|
||||||
|
certPEM={certPEM}
|
||||||
|
keyPEM={keyPEM}
|
||||||
|
applyNow={applyNow}
|
||||||
|
savingSSL={savingSSL}
|
||||||
|
onRefresh={fetchSSL}
|
||||||
|
onEnabledChange={setSSLEnabled}
|
||||||
|
onModeChange={handleSSLModeChange}
|
||||||
|
onTargetChange={setSSLTarget}
|
||||||
|
onEmailChange={setSSLEmail}
|
||||||
|
onCertChange={setCertPEM}
|
||||||
|
onKeyChange={setKeyPEM}
|
||||||
|
onApplyNowChange={setApplyNow}
|
||||||
|
onSave={handleSaveSSL}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
<div className="px-3 py-2">
|
||||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
<div className="text-[11px] text-gray-500">等待中</div>
|
||||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.pending ?? 0}</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -292,7 +448,7 @@ interface WebSSHOriginCardProps {
|
|||||||
|
|
||||||
function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
||||||
return (
|
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">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||||
<Terminal className="h-4 w-4" />WebSSH Origin 白名单
|
<Terminal className="h-4 w-4" />WebSSH Origin 白名单
|
||||||
@@ -307,7 +463,7 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
|||||||
<textarea
|
<textarea
|
||||||
value={props.originsText}
|
value={props.originsText}
|
||||||
onChange={(e) => props.onOriginsTextChange(e.target.value)}
|
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"
|
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,10 +471,12 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
|||||||
<div className="truncate font-mono" title={props.settings?.current_origin || ''}>当前面板来源:{props.settings?.current_origin || '-'}</div>
|
<div className="truncate font-mono" title={props.settings?.current_origin || ''}>当前面板来源:{props.settings?.current_origin || '-'}</div>
|
||||||
<div className="mt-1">默认允许当前面板来源和本机回环来源;额外域名每行填写一个完整 Origin。</div>
|
<div className="mt-1">默认允许当前面板来源和本机回环来源;额外域名每行填写一个完整 Origin。</div>
|
||||||
</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">
|
||||||
<Upload className="h-4 w-4" />
|
<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">
|
||||||
{props.saving ? '保存中...' : '保存 Origin 白名单'}
|
<Upload className="h-4 w-4" />
|
||||||
</button>
|
{props.saving ? '保存中...' : '保存 Origin 白名单'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -425,10 +583,12 @@ function SSLCard(props: SSLCardProps) {
|
|||||||
保存后自动重启服务并立即生效
|
保存后自动重启服务并立即生效
|
||||||
</label>
|
</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">
|
||||||
<Upload className="h-4 w-4" />
|
<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">
|
||||||
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
|
<Upload className="h-4 w-4" />
|
||||||
</button>
|
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]}`
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Copy, HardDrive, KeyRound, LogIn, RefreshCw, Save, ScrollText, UserCog, X } from 'lucide-react'
|
import { Copy, HardDrive, KeyRound, LogIn, RefreshCw, Save, ScrollText, UserCog, X } from 'lucide-react'
|
||||||
import { useDialog } from '../components/Dialog'
|
import { useDialog } from '../components/Dialog'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import api, { AuditLog, ImageInfo, LoginLog, getImages, updateSubUserImages } from '../services/api'
|
import api, { AuditLog, ImageInfo, LoginLog, getImages, updateSubUserImages } from '../services/api'
|
||||||
import { copyToClipboard } from '../utils/clipboard'
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ interface AuditLogExt extends AuditLog {
|
|||||||
|
|
||||||
export default function SubUserManagement() {
|
export default function SubUserManagement() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const { t } = useLanguage()
|
||||||
const [users, setUsers] = useState<SubUserItem[]>([])
|
const [users, setUsers] = useState<SubUserItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
|
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
|
||||||
@@ -173,8 +175,10 @@ export default function SubUserManagement() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-black dark:text-white">子用户管理</h1>
|
<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">容器分配的子用户列表,共 {users.length} 个</p>
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('容器分配的子用户列表,共')} {users.length} {t('个')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export interface Container {
|
|||||||
uuid: string
|
uuid: string
|
||||||
name: string
|
name: string
|
||||||
virtualization?: string
|
virtualization?: string
|
||||||
|
storage_pool_id?: string
|
||||||
|
storage_path?: string
|
||||||
template: string
|
template: string
|
||||||
vcpu: number
|
vcpu: number
|
||||||
ram_mb: number
|
ram_mb: number
|
||||||
@@ -143,6 +145,7 @@ export interface CreateContainerRequest {
|
|||||||
name: string
|
name: string
|
||||||
virtualization: string
|
virtualization: string
|
||||||
template_id: string
|
template_id: string
|
||||||
|
storage_pool_id?: string
|
||||||
vcpu: number
|
vcpu: number
|
||||||
cpu_percent: number
|
cpu_percent: number
|
||||||
ram_mb: number
|
ram_mb: number
|
||||||
@@ -180,6 +183,51 @@ export interface CreateContainerRequest {
|
|||||||
expires_at: string
|
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 {
|
export interface ReinstallContainerOptions {
|
||||||
ssh_auth_mode?: string
|
ssh_auth_mode?: string
|
||||||
ssh_password?: string
|
ssh_password?: string
|
||||||
@@ -258,6 +306,10 @@ export interface HostInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateSnapshotOptions {
|
||||||
|
storage_pool_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostMetricPoint {
|
export interface HostMetricPoint {
|
||||||
ts: number
|
ts: number
|
||||||
cpu: number
|
cpu: number
|
||||||
@@ -430,6 +482,18 @@ export interface AuditLog {
|
|||||||
export const getLoginLogs = () =>
|
export const getLoginLogs = () =>
|
||||||
api.get<APIResponse<LoginLog[]>>('/login-logs')
|
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 {
|
export interface SSLCertificateInfo {
|
||||||
subject: string
|
subject: string
|
||||||
issuer: string
|
issuer: string
|
||||||
@@ -741,6 +805,12 @@ export const getHostHistory = () =>
|
|||||||
export const getHostReport = () =>
|
export const getHostReport = () =>
|
||||||
api.get<APIResponse<HostProbeReport>>('/host-report')
|
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
|
// Snapshots
|
||||||
export interface Snapshot {
|
export interface Snapshot {
|
||||||
id: string
|
id: string
|
||||||
@@ -775,8 +845,8 @@ export const getSnapshots = () =>
|
|||||||
export const getContainerSnapshots = (id: ContainerIdentifier) =>
|
export const getContainerSnapshots = (id: ContainerIdentifier) =>
|
||||||
api.get<APIResponse<ContainerSnapshotsResponse>>(`/containers/${id}/snapshots`)
|
api.get<APIResponse<ContainerSnapshotsResponse>>(`/containers/${id}/snapshots`)
|
||||||
|
|
||||||
export const createContainerSnapshot = (id: ContainerIdentifier) =>
|
export const createContainerSnapshot = (id: ContainerIdentifier, options?: CreateSnapshotOptions) =>
|
||||||
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, {}, { timeout: 600000 })
|
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, options || {}, { timeout: 600000 })
|
||||||
|
|
||||||
export const deleteContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
|
export const deleteContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
|
||||||
api.delete<APIResponse>(`/containers/${id}/snapshots/${snapshotId}`, { timeout: 600000 })
|
api.delete<APIResponse>(`/containers/${id}/snapshots/${snapshotId}`, { timeout: 600000 })
|
||||||
@@ -818,6 +888,8 @@ export interface Task {
|
|||||||
container_name: string
|
container_name: string
|
||||||
status: string
|
status: string
|
||||||
error?: string
|
error?: string
|
||||||
|
stage?: string
|
||||||
|
stage_detail?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
template_id?: string
|
template_id?: string
|
||||||
config?: CreateContainerRequest
|
config?: CreateContainerRequest
|
||||||
|
|||||||
@@ -392,6 +392,23 @@ const exact: Record<string, string> = {
|
|||||||
'暂未获取到宿主机信息': 'No host information available',
|
'暂未获取到宿主机信息': 'No host information available',
|
||||||
'面板资源状态与容器概览': 'Panel resource status and container overview',
|
'面板资源状态与容器概览': 'Panel resource status and container overview',
|
||||||
'宿主机资源状态与容器概览': 'Host 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',
|
'账号设置': 'Account Settings',
|
||||||
'当前用户名': 'Current Username',
|
'当前用户名': 'Current Username',
|
||||||
'新用户名,留空则不修改': 'New Username, leave blank to keep unchanged',
|
'新用户名,留空则不修改': 'New Username, leave blank to keep unchanged',
|
||||||
@@ -401,6 +418,12 @@ const exact: Record<string, string> = {
|
|||||||
'至少 6 位': 'At least 6 characters',
|
'至少 6 位': 'At least 6 characters',
|
||||||
'输入当前密码以确认修改': 'Enter current password to confirm changes',
|
'输入当前密码以确认修改': 'Enter current password to confirm changes',
|
||||||
'保存修改': 'Save 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',
|
'SSL 证书': 'SSL Certificate',
|
||||||
'启用 HTTPS / WSS': 'Enable HTTPS / WSS',
|
'启用 HTTPS / WSS': 'Enable HTTPS / WSS',
|
||||||
'IP / 域名': 'IP / Domain',
|
'IP / 域名': 'IP / Domain',
|
||||||
@@ -761,6 +784,31 @@ const exact: Record<string, string> = {
|
|||||||
'初始化失败': 'Initialization failed',
|
'初始化失败': 'Initialization failed',
|
||||||
'初始化完成': 'Initialization complete',
|
'初始化完成': 'Initialization complete',
|
||||||
'排队等待': 'Queued',
|
'排队等待': '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',
|
'处理中': 'Processing',
|
||||||
'未知系统': 'Unknown system',
|
'未知系统': 'Unknown system',
|
||||||
'处理失败': 'Failed',
|
'处理失败': 'Failed',
|
||||||
@@ -886,6 +934,132 @@ const exact: Record<string, string> = {
|
|||||||
'生成新密码': 'Generate new password',
|
'生成新密码': 'Generate new password',
|
||||||
'自定义密码': 'Custom password',
|
'自定义密码': 'Custom password',
|
||||||
'生成密码': 'Generate 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[] = [
|
const artifactPatterns: RegExp[] = [
|
||||||
@@ -939,6 +1113,7 @@ const replacements: Array<[RegExp, string]> = [
|
|||||||
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
|
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
|
||||||
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
|
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
|
||||||
[/共\s*(\d+)\s*个\s*容器/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'],
|
[/(\d+)\s*个前缀,(\d+)\s*个地址已分配/g, '$1 prefixes, $2 addresses assigned'],
|
||||||
[/共\s*(\d+)\s*条/g, 'Total $1'],
|
[/共\s*(\d+)\s*条/g, 'Total $1'],
|
||||||
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
|
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
|
||||||
@@ -988,6 +1163,7 @@ const replacements: Array<[RegExp, string]> = [
|
|||||||
[/^(.+?)\s*-\s*登录日志$/g, '$1 - Login Logs'],
|
[/^(.+?)\s*-\s*登录日志$/g, '$1 - Login Logs'],
|
||||||
[/^(.+?)。下次登录生效$/g, '$1. Takes effect at next login'],
|
[/^(.+?)。下次登录生效$/g, '$1. Takes effect at next login'],
|
||||||
[/阶段:(.+)$/g, 'Stage: $1'],
|
[/阶段:(.+)$/g, 'Stage: $1'],
|
||||||
|
[/正在初始化:(.+)$/g, 'Initializing: $1'],
|
||||||
[/\$\{days\}天/g, '${days} days'],
|
[/\$\{days\}天/g, '${days} days'],
|
||||||
[/\$\{hours\}小时/g, '${hours} hours'],
|
[/\$\{hours\}小时/g, '${hours} hours'],
|
||||||
[/\$\{hours\}\s*小时/g, '${hours} hours'],
|
[/\$\{hours\}\s*小时/g, '${hours} hours'],
|
||||||
|
|||||||
Reference in New Issue
Block a user