mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
优化功能体验
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
+275
-93
@@ -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"
|
||||||
@@ -16,23 +18,143 @@ import (
|
|||||||
|
|
||||||
// ImageInfo represents a template image with its download/enable status.
|
// ImageInfo represents a template image with its download/enable status.
|
||||||
type ImageInfo struct {
|
type ImageInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Distro string `json:"distro"`
|
Distro string `json:"distro"`
|
||||||
Release string `json:"release"`
|
Release string `json:"release"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Downloaded bool `json:"downloaded"`
|
Downloaded bool `json:"downloaded"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Downloading bool `json:"downloading"`
|
Downloading bool `json:"downloading"`
|
||||||
SizeBytes int64 `json:"size_bytes"`
|
Progress int `json:"progress"`
|
||||||
ManualPath string `json:"manual_path,omitempty"`
|
DownloadedBytes int64 `json:"downloaded_bytes"`
|
||||||
Desktop string `json:"desktop,omitempty"`
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
Stage string `json:"stage,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
ManualPath string `json:"manual_path,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,54 +223,65 @@ 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,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Type: config.VirtualizationLXC,
|
Type: config.VirtualizationLXC,
|
||||||
Distro: t.Distro,
|
Distro: t.Distro,
|
||||||
Release: t.Release,
|
Release: t.Release,
|
||||||
Arch: t.Arch,
|
Arch: t.Arch,
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
Downloaded: downloaded,
|
Downloaded: downloaded,
|
||||||
Enabled: enabledSet[t.ID],
|
Enabled: enabledSet[t.ID],
|
||||||
Downloading: downloading,
|
Downloading: dl.Downloading,
|
||||||
SizeBytes: size,
|
Progress: dl.Progress,
|
||||||
|
DownloadedBytes: dl.DownloadedBytes,
|
||||||
|
TotalBytes: dl.TotalBytes,
|
||||||
|
Stage: dl.Stage,
|
||||||
|
Error: dl.Error,
|
||||||
|
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" {
|
||||||
manualPath = kvm.ImagePath(t.ID)
|
manualPath = kvm.ImagePath(t.ID)
|
||||||
}
|
}
|
||||||
images = append(images, ImageInfo{
|
images = append(images, ImageInfo{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Type: config.VirtualizationKVM,
|
Type: config.VirtualizationKVM,
|
||||||
Distro: t.Distro,
|
Distro: t.Distro,
|
||||||
Release: t.Release,
|
Release: t.Release,
|
||||||
Arch: t.Arch,
|
Arch: t.Arch,
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
Downloaded: downloaded,
|
Downloaded: downloaded,
|
||||||
Enabled: enabledSet[t.ID],
|
Enabled: enabledSet[t.ID],
|
||||||
Downloading: downloading,
|
Downloading: dl.Downloading,
|
||||||
SizeBytes: size,
|
Progress: dl.Progress,
|
||||||
ManualPath: manualPath,
|
DownloadedBytes: dl.DownloadedBytes,
|
||||||
Desktop: t.Desktop,
|
TotalBytes: dl.TotalBytes,
|
||||||
|
Stage: dl.Stage,
|
||||||
|
Error: dl.Error,
|
||||||
|
SizeBytes: size,
|
||||||
|
ManualPath: manualPath,
|
||||||
|
Desktop: t.Desktop,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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})
|
})
|
||||||
return
|
})
|
||||||
}
|
if err != nil {
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
|
if ctx.Err() != nil {
|
||||||
|
os.Remove(kvm.ImagePath(image.ID) + ".tmp")
|
||||||
|
os.Remove(kvm.ImagePath(image.ID))
|
||||||
|
finishImageDownload(image.ID, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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() {
|
go func(tmpl lxc.Template) {
|
||||||
imageDownloadsMu.Lock()
|
// Download via lxc-create with a temp container, then destroy it.
|
||||||
delete(imageDownloads, req.TemplateID)
|
tmpName := lxcImageDownloadTempName(tmpl.ID)
|
||||||
imageDownloadsMu.Unlock()
|
args := []string{"-n", tmpName, "-t", "download", "--",
|
||||||
}()
|
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
|
||||||
|
if tmpl.Variant != "" {
|
||||||
// Auto-enable on download
|
args = append(args, "--variant", tmpl.Variant)
|
||||||
ensureImageEnabled(tmpl.ID)
|
}
|
||||||
|
updateImageDownload(tmpl.ID, func(st *imageDownloadStatus) {
|
||||||
// Download via lxc-create with a temp container, then destroy it.
|
st.Stage = "lxc-create"
|
||||||
tmpName := fmt.Sprintf("clicd-img-dl-%s", tmpl.ID)
|
|
||||||
args := []string{"-n", tmpName, "-t", "download", "--",
|
|
||||||
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
|
|
||||||
if tmpl.Variant != "" {
|
|
||||||
args = append(args, "--variant", tmpl.Variant)
|
|
||||||
}
|
|
||||||
cmd := exec.Command("lxc-create", args...)
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
|
|
||||||
// Clean up the temp container unconditionally.
|
|
||||||
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run()
|
|
||||||
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{
|
|
||||||
Success: false,
|
|
||||||
Message: fmt.Sprintf("Download failed: %v, output: %s", err, string(output)),
|
|
||||||
})
|
})
|
||||||
|
cmd := exec.CommandContext(ctx, "lxc-create", args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
// Clean up the temp container unconditionally.
|
||||||
|
cleanupLXCImageDownloadTemp(tmpl.ID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
finishImageDownload(tmpl.ID, nil)
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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))))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
<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" />
|
||||||
|
{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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
|||||||
Reference in New Issue
Block a user