优化功能体验

This commit is contained in:
MengMengCode
2026-06-08 01:21:10 +08:00
parent 7d48889eea
commit ade1c6c093
12 changed files with 663 additions and 154 deletions
+43 -1
View File
@@ -2,10 +2,12 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/lxc" "clicd/internal/lxc"
@@ -394,7 +396,24 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
return return
} }
newPassword, err := resetPasswordByRuntime(id) var req struct {
Password string `json:"password"`
}
if r.Body != nil {
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil && err.Error() != "EOF" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
}
password := strings.TrimSpace(req.Password)
if password != "" {
if err := validateSSHPassword(password); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
}
newPassword, err := resetPasswordByRuntime(id, password)
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
@@ -406,6 +425,29 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
}) })
} }
func validateSSHPassword(password string) error {
if len(password) < 8 || len(password) > 64 {
return fmt.Errorf("密码长度必须为 8-64 位")
}
hasLetter := false
hasDigit := false
for _, r := range password {
if unicode.IsSpace(r) {
return fmt.Errorf("密码不能包含空白字符")
}
if unicode.IsLetter(r) {
hasLetter = true
}
if unicode.IsDigit(r) {
hasDigit = true
}
}
if !hasLetter || !hasDigit {
return fmt.Errorf("密码至少需要包含字母和数字")
}
return nil
}
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) { func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
var pm config.PortMapping var pm config.PortMapping
if err := json.NewDecoder(r.Body).Decode(&pm); err != nil { if err := json.NewDecoder(r.Body).Decode(&pm); err != nil {
+228 -46
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -8,6 +9,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sync" "sync"
"time"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/kvm" "clicd/internal/kvm"
@@ -26,13 +28,133 @@ type ImageInfo struct {
Downloaded bool `json:"downloaded"` Downloaded bool `json:"downloaded"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Downloading bool `json:"downloading"` Downloading bool `json:"downloading"`
Progress int `json:"progress"`
DownloadedBytes int64 `json:"downloaded_bytes"`
TotalBytes int64 `json:"total_bytes"`
Stage string `json:"stage,omitempty"`
Error string `json:"error,omitempty"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
ManualPath string `json:"manual_path,omitempty"` ManualPath string `json:"manual_path,omitempty"`
Desktop string `json:"desktop,omitempty"` Desktop string `json:"desktop,omitempty"`
} }
var imageDownloadsMu sync.Mutex var imageDownloadsMu sync.Mutex
var imageDownloads = map[string]bool{} var imageDownloads = map[string]*imageDownloadStatus{}
type imageDownloadStatus struct {
Downloading bool
Progress int
DownloadedBytes int64
TotalBytes int64
Stage string
Error string
Cancel context.CancelFunc
UpdatedAt time.Time
}
type imageDownloadSnapshot struct {
Downloading bool
Progress int
DownloadedBytes int64
TotalBytes int64
Stage string
Error string
}
func imageDownloadInfo(id string) imageDownloadSnapshot {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
if st == nil {
return imageDownloadSnapshot{}
}
return imageDownloadSnapshot{
Downloading: st.Downloading,
Progress: st.Progress,
DownloadedBytes: st.DownloadedBytes,
TotalBytes: st.TotalBytes,
Stage: st.Stage,
Error: st.Error,
}
}
func startImageDownload(id, stage string) (context.Context, bool) {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
if st := imageDownloads[id]; st != nil && st.Downloading {
return nil, false
}
ctx, cancel := context.WithCancel(context.Background())
imageDownloads[id] = &imageDownloadStatus{
Downloading: true,
Stage: stage,
Cancel: cancel,
UpdatedAt: time.Now(),
}
return ctx, true
}
func updateImageDownload(id string, update func(*imageDownloadStatus)) {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
if st == nil {
return
}
update(st)
st.UpdatedAt = time.Now()
}
func finishImageDownload(id string, err error) {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
if st == nil {
return
}
st.Downloading = false
st.Cancel = nil
st.UpdatedAt = time.Now()
if err != nil {
st.Error = err.Error()
return
}
delete(imageDownloads, id)
}
func clearImageDownload(id string) {
imageDownloadsMu.Lock()
delete(imageDownloads, id)
imageDownloadsMu.Unlock()
}
func isImageDownloadActive(id string) bool {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
return st != nil && st.Downloading
}
func lxcImageDownloadTempName(id string) string {
return fmt.Sprintf("clicd-img-dl-%s", id)
}
func cleanupLXCImageDownloadTemp(id string) {
tmpName := lxcImageDownloadTempName(id)
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run()
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
}
func cleanupOldImageDownloadErrors() {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
cutoff := time.Now().Add(-10 * time.Minute)
for id, st := range imageDownloads {
if !st.Downloading && st.UpdatedAt.Before(cutoff) {
delete(imageDownloads, id)
}
}
}
// isImageDownloaded checks if the LXC download cache exists for a template. // isImageDownloaded checks if the LXC download cache exists for a template.
func isImageDownloaded(distro, release, arch string) bool { func isImageDownloaded(distro, release, arch string) bool {
@@ -101,11 +223,12 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
} }
enabledSet := getEnabledImageSet() enabledSet := getEnabledImageSet()
cleanupOldImageDownloadErrors()
templates := lxc.GetTemplates() templates := lxc.GetTemplates()
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages())) images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
for _, t := range templates { for _, t := range templates {
_, downloading := imageDownloads[t.ID] dl := imageDownloadInfo(t.ID)
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch) downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
images = append(images, ImageInfo{ images = append(images, ImageInfo{
ID: t.ID, ID: t.ID,
@@ -117,12 +240,17 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
Description: t.Description, Description: t.Description,
Downloaded: downloaded, Downloaded: downloaded,
Enabled: enabledSet[t.ID], Enabled: enabledSet[t.ID],
Downloading: downloading, Downloading: dl.Downloading,
Progress: dl.Progress,
DownloadedBytes: dl.DownloadedBytes,
TotalBytes: dl.TotalBytes,
Stage: dl.Stage,
Error: dl.Error,
SizeBytes: size, SizeBytes: size,
}) })
} }
for _, t := range kvm.GetImages() { for _, t := range kvm.GetImages() {
_, downloading := imageDownloads[t.ID] dl := imageDownloadInfo(t.ID)
downloaded, size := kvm.ImageDownloadedInfo(t.ID) downloaded, size := kvm.ImageDownloadedInfo(t.ID)
manualPath := "" manualPath := ""
if t.Distro == "windows" { if t.Distro == "windows" {
@@ -138,7 +266,12 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
Description: t.Description, Description: t.Description,
Downloaded: downloaded, Downloaded: downloaded,
Enabled: enabledSet[t.ID], Enabled: enabledSet[t.ID],
Downloading: downloading, Downloading: dl.Downloading,
Progress: dl.Progress,
DownloadedBytes: dl.DownloadedBytes,
TotalBytes: dl.TotalBytes,
Stage: dl.Stage,
Error: dl.Error,
SizeBytes: size, SizeBytes: size,
ManualPath: manualPath, ManualPath: manualPath,
Desktop: t.Desktop, Desktop: t.Desktop,
@@ -148,7 +281,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
} }
// HandleImageDownload downloads a template image from the LXC image server. // HandleImageDownload starts a template image download in the background.
func HandleImageDownload(w http.ResponseWriter, r *http.Request) { func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
@@ -172,82 +305,127 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
} }
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok { if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
ensureImageEnabled(image.ID) ensureImageEnabled(image.ID)
clearImageDownload(image.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return return
} }
imageDownloadsMu.Lock() ctx, ok := startImageDownload(image.ID, "downloading")
if imageDownloads[req.TemplateID] { if !ok {
imageDownloadsMu.Unlock()
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"}) jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return return
} }
imageDownloads[req.TemplateID] = true go func(image kvm.Image) {
imageDownloadsMu.Unlock() err := kvm.DownloadImageWithProgress(ctx, image, func(p kvm.DownloadProgress) {
defer func() { updateImageDownload(image.ID, func(st *imageDownloadStatus) {
imageDownloadsMu.Lock() if p.Stage != "" {
delete(imageDownloads, req.TemplateID) st.Stage = p.Stage
imageDownloadsMu.Unlock() }
}() if p.DownloadedBytes > 0 || p.TotalBytes > 0 {
ensureImageEnabled(image.ID) st.DownloadedBytes = p.DownloadedBytes
if err := kvm.DownloadImage(*image); err != nil { st.TotalBytes = p.TotalBytes
message := "Download failed: " + err.Error() }
st.Progress = p.Percent
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: message}) })
})
if err != nil {
if ctx.Err() != nil {
os.Remove(kvm.ImagePath(image.ID) + ".tmp")
os.Remove(kvm.ImagePath(image.ID))
finishImageDownload(image.ID, nil)
return return
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) finishImageDownload(image.ID, err)
return
}
ensureImageEnabled(image.ID)
finishImageDownload(image.ID, nil)
}(*image)
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
return 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) {
ensureImageEnabled(tmpl.ID) ensureImageEnabled(tmpl.ID)
clearImageDownload(tmpl.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return return
} }
// Already downloading? ctx, ok := startImageDownload(tmpl.ID, "lxc-create")
imageDownloadsMu.Lock() if !ok {
if imageDownloads[req.TemplateID] {
imageDownloadsMu.Unlock()
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"}) jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return return
} }
imageDownloads[req.TemplateID] = true
imageDownloadsMu.Unlock()
defer func() {
imageDownloadsMu.Lock()
delete(imageDownloads, req.TemplateID)
imageDownloadsMu.Unlock()
}()
// Auto-enable on download
ensureImageEnabled(tmpl.ID)
go func(tmpl lxc.Template) {
// Download via lxc-create with a temp container, then destroy it. // Download via lxc-create with a temp container, then destroy it.
tmpName := fmt.Sprintf("clicd-img-dl-%s", tmpl.ID) tmpName := lxcImageDownloadTempName(tmpl.ID)
args := []string{"-n", tmpName, "-t", "download", "--", args := []string{"-n", tmpName, "-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 != "" {
args = append(args, "--variant", tmpl.Variant) args = append(args, "--variant", tmpl.Variant)
} }
cmd := exec.Command("lxc-create", args...) updateImageDownload(tmpl.ID, func(st *imageDownloadStatus) {
st.Stage = "lxc-create"
})
cmd := exec.CommandContext(ctx, "lxc-create", args...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
// Clean up the temp container unconditionally. // Clean up the temp container unconditionally.
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run() cleanupLXCImageDownloadTemp(tmpl.ID)
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
if err != nil { if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{ if ctx.Err() != nil {
Success: false, finishImageDownload(tmpl.ID, nil)
Message: fmt.Sprintf("Download failed: %v, output: %s", err, string(output)), return
}) }
err = fmt.Errorf("Download failed: %v, output: %s", err, string(output))
finishImageDownload(tmpl.ID, err)
return
}
ensureImageEnabled(tmpl.ID)
finishImageDownload(tmpl.ID, nil)
}(*tmpl)
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
}
// HandleImageCancel cancels an in-progress image download.
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
var req struct {
TemplateID string `json:"template_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return return
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) imageDownloadsMu.Lock()
st := imageDownloads[req.TemplateID]
if st == nil || !st.Downloading || st.Cancel == nil {
imageDownloadsMu.Unlock()
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "No active download"})
return
}
cancel := st.Cancel
st.Stage = "canceling"
st.UpdatedAt = time.Now()
imageDownloadsMu.Unlock()
cancel()
if image := kvm.FindImage(req.TemplateID); image != nil {
os.Remove(kvm.ImagePath(image.ID) + ".tmp")
os.Remove(kvm.ImagePath(image.ID))
}
if tmpl := lxc.FindTemplate(req.TemplateID); tmpl != nil {
go cleanupLXCImageDownloadTemp(tmpl.ID)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Cancel requested"})
} }
// HandleImageDelete deletes a cached template image from disk. // HandleImageDelete deletes a cached template image from disk.
@@ -264,6 +442,10 @@ func HandleImageDelete(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
} }
if isImageDownloadActive(req.TemplateID) {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Image is downloading; cancel it before deleting"})
return
}
tmpl := lxc.FindTemplate(req.TemplateID) tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil { if tmpl == nil {
+3 -3
View File
@@ -72,12 +72,12 @@ func reinstallByRuntime(id int, templateID string) error {
return lxcManager.ReinstallContainer(id, templateID) return lxcManager.ReinstallContainer(id, templateID)
} }
func resetPasswordByRuntime(id int) (string, error) { func resetPasswordByRuntime(id int, password string) (string, error) {
c := config.FindContainer(id) c := config.FindContainer(id)
if c != nil && c.IsKVM() { if c != nil && c.IsKVM() {
return kvmManager.ResetSSHPassword(id) return kvmManager.ResetSSHPassword(id, password)
} }
return lxcManager.ResetSSHPassword(id) return lxcManager.ResetSSHPassword(id, password)
} }
func assignIPv6ByRuntime(id int) (*config.Container, error) { func assignIPv6ByRuntime(id int) (*config.Container, error) {
+80 -13
View File
@@ -2,6 +2,7 @@ package kvm
import ( import (
"bytes" "bytes"
"context"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
@@ -114,7 +115,22 @@ func ImageDownloadedInfo(id string) (bool, int64) {
return true, info.Size() return true, info.Size()
} }
// DownloadProgress reports KVM image download/conversion progress.
type DownloadProgress struct {
Stage string
DownloadedBytes int64
TotalBytes int64
Percent int
}
// DownloadProgressFunc receives download progress updates.
type DownloadProgressFunc func(DownloadProgress)
func DownloadImage(image Image) error { func DownloadImage(image Image) error {
return DownloadImageWithProgress(context.Background(), image, nil)
}
func DownloadImageWithProgress(ctx context.Context, image Image, progress DownloadProgressFunc) error {
if err := os.MkdirAll(CacheDir(), 0755); err != nil { if err := os.MkdirAll(CacheDir(), 0755); err != nil {
return err return err
} }
@@ -134,11 +150,15 @@ func DownloadImage(image Image) error {
tmp := target + ".tmp" tmp := target + ".tmp"
_ = os.Remove(tmp) _ = os.Remove(tmp)
if image.Distro == "windows" { if image.Distro == "windows" {
if err := downloadFileWithValidator(image.URL, tmp, validateWindowsISOResponse(target)); err != nil { if err := downloadFileWithValidator(ctx, image.URL, tmp, validateWindowsISOResponse(target), progress); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return err return err
} }
} else if err := downloadFile(image.URL, tmp); err != nil { } else if err := downloadFile(ctx, image.URL, tmp, progress); err != nil {
_ = os.Remove(tmp)
return err
}
if err := ctx.Err(); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return err return err
} }
@@ -153,8 +173,12 @@ func DownloadImage(image Image) error {
return err return err
} }
} else { } else {
if err := normalizeQCOW2(tmp, target); err != nil { if progress != nil {
progress(DownloadProgress{Stage: "converting", Percent: 100})
}
if err := normalizeQCOW2(ctx, tmp, target); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
_ = os.Remove(target)
return err return err
} }
} }
@@ -168,11 +192,11 @@ func DeleteImage(id string) error {
type downloadResponseValidator func(*http.Response) error type downloadResponseValidator func(*http.Response) error
func downloadFile(url, target string) error { func downloadFile(ctx context.Context, url, target string, progress DownloadProgressFunc) error {
return downloadFileWithValidator(url, target, nil) return downloadFileWithValidator(ctx, url, target, nil, progress)
} }
func downloadFileWithValidator(url, target string, validate downloadResponseValidator) error { func downloadFileWithValidator(ctx context.Context, url, target string, validate downloadResponseValidator, progress DownloadProgressFunc) error {
client := http.Client{ client := http.Client{
Timeout: 30 * time.Minute, Timeout: 30 * time.Minute,
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
@@ -186,7 +210,7 @@ func downloadFileWithValidator(url, target string, validate downloadResponseVali
return nil return nil
}, },
} }
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return err return err
} }
@@ -210,7 +234,48 @@ func downloadFileWithValidator(url, target string, validate downloadResponseVali
return err return err
} }
defer out.Close() defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil { total := resp.ContentLength
if total < 0 {
total = 0
}
if progress != nil {
progress(DownloadProgress{Stage: "downloading", TotalBytes: total})
}
buf := make([]byte, 256*1024)
var downloaded int64
for {
if err := ctx.Err(); err != nil {
return err
}
n, readErr := resp.Body.Read(buf)
if n > 0 {
written, writeErr := out.Write(buf[:n])
downloaded += int64(written)
if writeErr != nil {
return writeErr
}
if written != n {
return io.ErrShortWrite
}
if progress != nil {
percent := 0
if total > 0 {
percent = int(downloaded * 100 / total)
if percent > 99 {
percent = 99
}
}
progress(DownloadProgress{Stage: "downloading", DownloadedBytes: downloaded, TotalBytes: total, Percent: percent})
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return readErr
}
}
if err := ctx.Err(); err != nil {
return err return err
} }
return out.Sync() return out.Sync()
@@ -266,11 +331,11 @@ func validateWindowsISO(path, target string) error {
return nil return nil
} }
func normalizeQCOW2(src, target string) error { func normalizeQCOW2(ctx context.Context, src, target string) error {
if err := requireCommand("qemu-img"); err != nil { if err := requireCommand("qemu-img"); err != nil {
return err return err
} }
cmd := exec.Command("qemu-img", "convert", "-O", "qcow2", src, target) cmd := exec.CommandContext(ctx, "qemu-img", "convert", "-O", "qcow2", src, target)
if output, err := cmd.CombinedOutput(); err != nil { if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("qemu-img convert failed: %v, output: %s", err, string(output)) return fmt.Errorf("qemu-img convert failed: %v, output: %s", err, string(output))
} }
@@ -647,7 +712,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
return m.StartContainer(id) return m.StartContainer(id)
} }
func (m *Manager) ResetSSHPassword(id int) (string, error) { func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
c := config.FindContainer(id) c := config.FindContainer(id)
if c == nil { if c == nil {
return "", fmt.Errorf("container not found: %d", id) return "", fmt.Errorf("container not found: %d", id)
@@ -658,7 +723,9 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
if c.Status != "running" { if c.Status != "running" {
return "", fmt.Errorf("KVM VM must be running before password reset") return "", fmt.Errorf("KVM VM must be running before password reset")
} }
password := generateRandomString(16) if strings.TrimSpace(password) == "" {
password = generateRandomString(16)
}
if err := runKVMGuestAgentSSHSetup(c.VirshName(), password); err == nil { if err := runKVMGuestAgentSSHSetup(c.VirshName(), password); err == nil {
c.SSHPassword = password c.SSHPassword = password
c.SSHHostKey = "" c.SSHHostKey = ""
@@ -1459,7 +1526,7 @@ func ensureVirtioWinISO() error {
virtioURL := "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" virtioURL := "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso"
tmp := virtioPath + ".tmp" tmp := virtioPath + ".tmp"
_ = os.Remove(tmp) _ = os.Remove(tmp)
if err := downloadFile(virtioURL, tmp); err != nil { if err := downloadFile(context.Background(), virtioURL, tmp, nil); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return fmt.Errorf("failed to download virtio-win.iso: %v", err) return fmt.Errorf("failed to download virtio-win.iso: %v", err)
} }
+53 -12
View File
@@ -11,14 +11,13 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"golang.org/x/sys/unix"
"clicd/internal/config" "clicd/internal/config"
) )
@@ -1005,11 +1004,10 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err != nil { if err != nil {
return err return err
} }
rootStat, ok := rootInfo.Sys().(*unix.Stat_t) rootDev, _, _, ok := fileStatFields(rootInfo)
if !ok { if !ok {
return fmt.Errorf("failed to read rootfs device for %s", rootfsPath) return fmt.Errorf("failed to read rootfs device for %s", rootfsPath)
} }
rootDev := rootStat.Dev
if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error { if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
@@ -1019,18 +1017,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err != nil { if err != nil {
return err return err
} }
stat, ok := info.Sys().(*unix.Stat_t) dev, uid, gid, ok := fileStatFields(info)
if !ok { if !ok {
return fmt.Errorf("failed to read uid/gid for %s", path) return fmt.Errorf("failed to read uid/gid for %s", path)
} }
if path != rootfsPath && stat.Dev != rootDev { if path != rootfsPath && dev != rootDev {
if info.IsDir() { if info.IsDir() {
return filepath.SkipDir return filepath.SkipDir
} }
return nil return nil
} }
uid := int(stat.Uid)
gid := int(stat.Gid)
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 { if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
return nil return nil
} }
@@ -1040,7 +1036,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if gid >= 0 && gid < 65536 { if gid >= 0 && gid < 65536 {
gid += gidBase gid += gidBase
} }
return unix.Lchown(path, uid, gid) return os.Lchown(path, uid, gid)
}); err != nil { }); err != nil {
return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err) return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err)
} }
@@ -1048,7 +1044,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil { if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil {
return err return err
} }
if err := unix.Lchown(marker, uidBase, gidBase); err != nil { if err := os.Lchown(marker, uidBase, gidBase); err != nil {
return err return err
} }
@@ -1063,6 +1059,48 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
return nil return nil
} }
func fileStatFields(info os.FileInfo) (dev uint64, uid int, gid int, ok bool) {
if info == nil || info.Sys() == nil {
return 0, 0, 0, false
}
stat := reflect.ValueOf(info.Sys())
if stat.Kind() == reflect.Pointer {
if stat.IsNil() {
return 0, 0, 0, false
}
stat = stat.Elem()
}
if stat.Kind() != reflect.Struct {
return 0, 0, 0, false
}
devValue, devOK := numericField(stat, "Dev")
uidValue, uidOK := numericField(stat, "Uid")
gidValue, gidOK := numericField(stat, "Gid")
if !devOK || !uidOK || !gidOK {
return 0, 0, 0, false
}
return devValue, int(uidValue), int(gidValue), true
}
func numericField(v reflect.Value, name string) (uint64, bool) {
field := v.FieldByName(name)
if !field.IsValid() {
return 0, false
}
switch field.Kind() {
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return field.Uint(), true
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
value := field.Int()
if value < 0 {
return 0, false
}
return uint64(value), true
default:
return 0, false
}
}
func (m *Manager) unmountRootfsChildMounts(rootfsPath string) { func (m *Manager) unmountRootfsChildMounts(rootfsPath string) {
rootAbs, err := filepath.Abs(rootfsPath) rootAbs, err := filepath.Abs(rootfsPath)
if err != nil { if err != nil {
@@ -1823,14 +1861,17 @@ pgrep -x sshd >/dev/null 2>&1 || exit 33
} }
// ResetSSHPassword resets the root password of a container // ResetSSHPassword resets the root password of a container
func (m *Manager) ResetSSHPassword(id int) (string, error) { func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
c := config.FindContainer(id) c := config.FindContainer(id)
if c == nil { if c == nil {
return "", fmt.Errorf("container not found: %d", id) return "", fmt.Errorf("container not found: %d", id)
} }
lxcName := c.LxcName() lxcName := c.LxcName()
newPassword := generateRandomString(16) newPassword := strings.TrimSpace(password)
if newPassword == "" {
newPassword = generateRandomString(16)
}
if c.Status == "running" { if c.Status == "running" {
c.SSHPassword = newPassword c.SSHPassword = newPassword
+1
View File
@@ -80,6 +80,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages))) mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload))) mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload)))
mux.HandleFunc("/api/images/cancel", corsMiddleware(api.AdminMiddleware(api.HandleImageCancel)))
mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete))) mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle))) mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle)))
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages)))) mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
+1 -1
View File
@@ -56,7 +56,7 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center"> <div className="w-10 h-10 flex items-center justify-center">
<Server className="w-5 h-5 text-gray-700" /> <Server className="w-5 h-5 text-gray-700" />
</div> </div>
<div> <div>
+2 -2
View File
@@ -83,14 +83,14 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700">
{!collapsed && ( {!collapsed && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center dark:bg-gray-800"> <div className="w-7 h-7 flex items-center justify-center">
<AppIcon className="w-5 h-5" /> <AppIcon className="w-5 h-5" />
</div> </div>
<span className="font-bold text-black text-sm dark:text-white">CLICD</span> <span className="font-bold text-black text-sm dark:text-white">CLICD</span>
</div> </div>
)} )}
{collapsed && ( {collapsed && (
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto dark:bg-gray-800"> <div className="w-7 h-7 flex items-center justify-center mx-auto">
<AppIcon className="w-5 h-5" /> <AppIcon className="w-5 h-5" />
</div> </div>
)} )}
+114 -13
View File
@@ -144,6 +144,10 @@ export default function ContainerDetail() {
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 }) const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
const [savingResource, setSavingResource] = useState(false) const [savingResource, setSavingResource] = useState(false)
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [showResetPassword, setShowResetPassword] = useState(false)
const [resetPasswordDraft, setResetPasswordDraft] = useState('')
const [resetPasswordResult, setResetPasswordResult] = useState('')
const [resetPasswordSaving, setResetPasswordSaving] = useState(false)
const [showSnapshots, setShowSnapshots] = useState(false) const [showSnapshots, setShowSnapshots] = useState(false)
const [snapshots, setSnapshots] = useState<Snapshot[]>([]) const [snapshots, setSnapshots] = useState<Snapshot[]>([])
const [snapshotQuota, setSnapshotQuota] = useState(3) const [snapshotQuota, setSnapshotQuota] = useState(3)
@@ -443,20 +447,58 @@ export default function ContainerDetail() {
} }
} }
const generateResetPassword = () => {
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
const digits = '23456789'
const symbols = '!@#$%*-_+='
const all = letters + digits + symbols
const pick = (chars: string) => chars[Math.floor(Math.random() * chars.length)]
let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all)
setResetPasswordDraft(password.split('').sort(() => Math.random() - 0.5).join(''))
setResetPasswordResult('')
}
const resetPasswordError = (password: string) => {
if (password.length < 8 || password.length > 64) return '密码长度必须为 8-64 位'
if (/\s/.test(password)) return '密码不能包含空白字符'
if (!/[A-Za-z]/.test(password)) return '密码至少需要包含字母'
if (!/\d/.test(password)) return '密码至少需要包含数字'
return ''
}
const handleResetPassword = async () => { const handleResetPassword = async () => {
if (!containerIdentifier || !(await dialog.confirm('重置密码', `确定要重置容器 ${container?.name} 的 SSH 密码吗?`))) return if (!containerIdentifier) return
const password = resetPasswordDraft.trim()
const validationError = resetPasswordError(password)
if (validationError) {
await dialog.alert('密码格式不正确', validationError)
return
}
setResetPasswordSaving(true)
try { try {
const res = await resetSSHPassword(containerIdentifier) const res = await resetSSHPassword(containerIdentifier, password)
if (res.data.success) { if (res.data.success) {
await dialog.alert('密码已重置', `新密码: ${(res.data.data as { password: string })?.password}`) const nextPassword = (res.data.data as { password: string })?.password || password
setResetPasswordResult(nextPassword)
setResetPasswordDraft(nextPassword)
await fetchContainer() await fetchContainer()
} }
} catch (err) { } catch (err: unknown) {
console.error(err) console.error(err)
dialog.alert('密码重置失败', '请稍后重试') const error = err as { response?: { data?: { message?: string } } }
dialog.alert('密码重置失败', error.response?.data?.message || '请稍后重试')
} finally {
setResetPasswordSaving(false)
} }
} }
const openResetPassword = () => {
setResetPasswordDraft('')
setResetPasswordResult('')
setShowResetPassword(true)
}
const handleAssignIPv6 = async () => { const handleAssignIPv6 = async () => {
if (!containerIdentifier) return if (!containerIdentifier) return
setActionLoading('ipv6') setActionLoading('ipv6')
@@ -782,7 +824,7 @@ export default function ContainerDetail() {
<div className="bg-white border border-gray-200 rounded-lg p-5"> <div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-14 h-14 bg-slate-100 rounded-lg flex items-center justify-center"> <div className="w-14 h-14 flex items-center justify-center">
{getTemplateIcon(container.template || '') || <Cpu className="w-7 h-7 text-slate-700" />} {getTemplateIcon(container.template || '') || <Cpu className="w-7 h-7 text-slate-700" />}
</div> </div>
<div> <div>
@@ -874,7 +916,18 @@ export default function ContainerDetail() {
)} )}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<Panel title="连接信息"> <Panel
title="连接信息"
extra={!isSubUser && !isWindows && !isSubUserPolicyBlocked ? (
<button
onClick={openResetPassword}
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-100 hover:text-black"
>
<Key className="w-3.5 h-3.5" />
SSH
</button>
) : undefined}
>
{isSubUserPolicyBlocked ? ( {isSubUserPolicyBlocked ? (
<div className="rounded-md border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700"> <div className="rounded-md border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
@@ -925,12 +978,6 @@ export default function ContainerDetail() {
)} )}
</div> </div>
</div> </div>
{!isSubUser && (
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black">
<Key className="w-3 h-3" />
SSH
</button>
)}
</> </>
)} )}
</Panel> </Panel>
@@ -1083,6 +1130,60 @@ export default function ContainerDetail() {
<ResourceStatsPanel range={range} onRangeChange={setRange} onRefresh={() => { fetchContainer(); fetchUsage() }} charts={charts} /> <ResourceStatsPanel range={range} onRangeChange={setRange} onRefresh={() => { fetchContainer(); fetchUsage() }} charts={charts} />
{showResetPassword && (
<Modal title="重置 SSH 密码" onClose={() => setShowResetPassword(false)}>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1"> SSH </label>
<div className="flex gap-2">
<input
type="text"
value={resetPasswordDraft}
onChange={(e) => { setResetPasswordDraft(e.target.value); setResetPasswordResult('') }}
placeholder="请输入 8-64 位,至少包含字母和数字"
className={inputClass}
/>
<button
type="button"
onClick={generateResetPassword}
className="px-3 py-2 border border-gray-300 rounded-md text-gray-600 hover:bg-gray-50 hover:text-black"
title="生成随机密码"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
{resetPasswordDraft && resetPasswordError(resetPasswordDraft) && (
<p className="mt-1 text-xs text-red-600">{resetPasswordError(resetPasswordDraft)}</p>
)}
</div>
{resetPasswordResult && (
<div className="p-3 bg-green-50 border border-green-200 rounded-md">
<div className="text-xs text-green-700 mb-1"></div>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-sm text-green-900 break-all">{resetPasswordResult}</span>
<button onClick={() => copyText(resetPasswordResult)} className="p-1 text-green-700 hover:text-green-900 rounded" title="复制">
<Copy className="w-4 h-4" />
</button>
</div>
</div>
)}
<p className="text-xs text-gray-500 leading-relaxed">
Linux LXC/KVM root SSH KVM guest agent SSH
</p>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowResetPassword(false)} className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-md hover:bg-gray-50"></button>
<button
onClick={handleResetPassword}
disabled={resetPasswordSaving || !resetPasswordDraft || !!resetPasswordError(resetPasswordDraft)}
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
>
{resetPasswordSaving ? '修改中...' : '确认修改'}
</button>
</div>
</div>
</Modal>
)}
{showSSH && ( {showSSH && (
<Modal title={`WebSSH - ${container.name}`} onClose={() => setShowSSH(false)} wide> <Modal title={`WebSSH - ${container.name}`} onClose={() => setShowSSH(false)} wide>
<div className="h-[70vh] min-h-[520px]"> <div className="h-[70vh] min-h-[520px]">
+78 -11
View File
@@ -9,8 +9,9 @@ import {
ToggleRight, ToggleRight,
Loader2, Loader2,
AlertCircle, AlertCircle,
X,
} from 'lucide-react' } from 'lucide-react'
import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api' import { getImages, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo } from '../services/api'
import { useDialog } from '../components/Dialog' import { useDialog } from '../components/Dialog'
export default function ImageManagement() { export default function ImageManagement() {
@@ -34,10 +35,14 @@ export default function ImageManagement() {
useEffect(() => { useEffect(() => {
fetchImages() fetchImages()
const interval = setInterval(fetchImages, 5000)
return () => clearInterval(interval)
}, [fetchImages]) }, [fetchImages])
useEffect(() => {
const hasDownloads = images.some((img) => img.downloading)
const interval = setInterval(fetchImages, hasDownloads ? 1500 : 5000)
return () => clearInterval(interval)
}, [fetchImages, images])
const handleDownload = async (templateId: string) => { const handleDownload = async (templateId: string) => {
setActionLoading(templateId) setActionLoading(templateId)
setError('') setError('')
@@ -51,6 +56,19 @@ export default function ImageManagement() {
} }
} }
const handleCancelDownload = async (templateId: string) => {
setActionLoading(templateId)
setError('')
try {
await cancelImageDownload(templateId)
await fetchImages()
} catch (err: unknown) {
setError(apiErrorMessage(err, '取消失败'))
} finally {
setActionLoading(null)
}
}
const handleDelete = async (templateId: string) => { const handleDelete = async (templateId: string) => {
if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return
setActionLoading(templateId) setActionLoading(templateId)
@@ -125,6 +143,7 @@ export default function ImageManagement() {
downloadedCount={lxcImages.filter((img) => img.downloaded).length} downloadedCount={lxcImages.filter((img) => img.downloaded).length}
totalCount={lxcImages.length} totalCount={lxcImages.length}
onDownload={handleDownload} onDownload={handleDownload}
onCancelDownload={handleCancelDownload}
onDelete={handleDelete} onDelete={handleDelete}
onToggle={handleToggle} onToggle={handleToggle}
/> />
@@ -136,6 +155,7 @@ export default function ImageManagement() {
downloadedCount={kvmImages.filter((img) => img.downloaded).length} downloadedCount={kvmImages.filter((img) => img.downloaded).length}
totalCount={kvmImages.length} totalCount={kvmImages.length}
onDownload={handleDownload} onDownload={handleDownload}
onCancelDownload={handleCancelDownload}
onDelete={handleDelete} onDelete={handleDelete}
onToggle={handleToggle} onToggle={handleToggle}
/> />
@@ -150,6 +170,7 @@ function ImageTable({
downloadedCount, downloadedCount,
totalCount, totalCount,
onDownload, onDownload,
onCancelDownload,
onDelete, onDelete,
onToggle, onToggle,
}: { }: {
@@ -159,6 +180,7 @@ function ImageTable({
downloadedCount: number downloadedCount: number
totalCount: number totalCount: number
onDownload: (id: string) => void onDownload: (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
}) { }) {
@@ -202,7 +224,7 @@ function ImageTable({
<tr key={img.id} className="hover:bg-gray-50 transition-colors"> <tr key={img.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0"> <span className="w-8 h-8 flex items-center justify-center flex-shrink-0">
{getTemplateIcon(img.id)} {getTemplateIcon(img.id)}
</span> </span>
<div> <div>
@@ -242,13 +264,18 @@ function ImageTable({
)} )}
{img.downloading && ( {img.downloading && (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-md text-amber-700 text-xs font-medium"> <button
<Loader2 className="w-3.5 h-3.5 animate-spin" /> onClick={() => onCancelDownload(img.id)}
... disabled={isBusy}
</span> className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-red-200 text-red-600 hover:bg-red-50 transition-colors text-xs font-medium disabled:opacity-50"
title="取消下载并清理临时文件"
>
{isBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <X className="w-3.5 h-3.5" />}
{isBusy ? '取消中...' : '取消'}
</button>
)} )}
{img.downloaded && ( {img.downloaded && !img.downloading && (
<> <>
<button <button
onClick={() => onToggle(img.id, img.enabled)} onClick={() => onToggle(img.id, img.enabled)}
@@ -287,10 +314,33 @@ 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 showProgress = img.stage === 'downloading' && progress > 0
return ( return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700"> <div className="inline-flex flex-col gap-1">
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700"
title={downloadStatusTitle(img)}
>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
{downloadStatusLabel(img)}
</span>
{showProgress && (
<span className="block h-1 w-24 overflow-hidden rounded-full bg-amber-100">
<span className="block h-full rounded-full bg-amber-500 transition-all" style={{ width: `${progress}%` }} />
</span>
)}
</div>
)
}
if (img.error) {
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-red-50 text-red-600"
title={img.error}
>
<AlertCircle className="w-3 h-3" />
</span> </span>
) )
} }
@@ -318,6 +368,23 @@ function StatusBadge({ img }: { img: ImageInfo }) {
) )
} }
function downloadStatusLabel(img: ImageInfo) {
if (img.stage === 'canceling') return '取消中'
if (img.stage === 'converting') return '转换中'
if (img.stage === 'lxc-create') return '下载中'
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
return '下载中'
}
function downloadStatusTitle(img: ImageInfo) {
const parts = [downloadStatusLabel(img)]
if (img.stage) parts.push(`阶段:${img.stage}`)
if (img.downloaded_bytes > 0 || img.total_bytes > 0) {
parts.push(`${formatSize(img.downloaded_bytes)} / ${formatSize(img.total_bytes)}`)
}
return parts.join('')
}
function isWindowsImage(img: ImageInfo) { function isWindowsImage(img: ImageInfo) {
return img.distro === 'windows' || img.id.toLowerCase().includes('windows') return img.distro === 'windows' || img.id.toLowerCase().includes('windows')
} }
+1 -1
View File
@@ -40,7 +40,7 @@ export default function Login() {
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8"> <div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center mb-4"> <div className="w-16 h-16 flex items-center justify-center mb-4">
<AppIcon className="w-10 h-10" /> <AppIcon className="w-10 h-10" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1> <h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
+11 -3
View File
@@ -245,8 +245,8 @@ export const restartContainer = (id: ContainerIdentifier) =>
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) => export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId }) api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
export const resetSSHPassword = (id: ContainerIdentifier) => export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`) api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
export const getContainerUsage = (id: ContainerIdentifier) => export const getContainerUsage = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`) api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
@@ -358,6 +358,11 @@ export interface ImageInfo {
downloaded: boolean downloaded: boolean
enabled: boolean enabled: boolean
downloading: boolean downloading: boolean
progress: number
downloaded_bytes: number
total_bytes: number
stage?: string
error?: string
size_bytes: number size_bytes: number
manual_path?: string manual_path?: string
desktop?: string desktop?: string
@@ -367,7 +372,10 @@ export const getImages = () =>
api.get<APIResponse<ImageInfo[]>>('/images') api.get<APIResponse<ImageInfo[]>>('/images')
export const downloadImage = (templateId: string) => export const downloadImage = (templateId: string) =>
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 1800000 }) // 30min timeout api.post<APIResponse>('/images/download', { template_id: templateId })
export const cancelImageDownload = (templateId: string) =>
api.post<APIResponse>('/images/cancel', { template_id: templateId })
export const deleteImage = (templateId: string) => export const deleteImage = (templateId: string) =>
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } }) api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })