From 5ebd4c2a91ccaf6d1c79f72ccbd6d05961ed50ba Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Wed, 6 May 2026 17:58:01 +0800 Subject: [PATCH] feat: add panel self-upgrade workflow (#492) --- docker-compose-v4.yml | 5 + docker-compose-v6.yml | 5 + go-backend/Dockerfile | 4 + go-backend/internal/http/handler/handler.go | 4 + .../internal/http/handler/system_upgrade.go | 564 ++++++++++++++++++ .../http/handler/system_upgrade_test.go | 398 ++++++++++++ vite-frontend/src/api/index.ts | 21 + vite-frontend/src/api/types.ts | 38 ++ vite-frontend/src/pages/config.tsx | 356 +++++++++++ .../src/shadcn-bridge/heroui/modal.tsx | 21 +- .../src/shadcn-bridge/heroui/select.tsx | 4 + 11 files changed, 1410 insertions(+), 10 deletions(-) create mode 100644 go-backend/internal/http/handler/system_upgrade.go create mode 100644 go-backend/internal/http/handler/system_upgrade_test.go diff --git a/docker-compose-v4.yml b/docker-compose-v4.yml index 3e4bbe0..c35b65c 100644 --- a/docker-compose-v4.yml +++ b/docker-compose-v4.yml @@ -15,10 +15,15 @@ services: JWT_SECRET: ${JWT_SECRET} SERVER_ADDR: :6365 TZ: Asia/Shanghai + FLUX_VERSION: ${FLUX_VERSION:-dev} + PANEL_DEPLOY_DIR: /opt/flvx-panel + PANEL_BACKEND_CONTAINER: flux-panel-backend ports: - "${BACKEND_PORT}:6365" volumes: - sqlite_data:/app/data + - /var/run/docker.sock:/var/run/docker.sock + - ./:/opt/flvx-panel networks: - gost-network stop_grace_period: 30s diff --git a/docker-compose-v6.yml b/docker-compose-v6.yml index b77fc2f..07cf2f7 100644 --- a/docker-compose-v6.yml +++ b/docker-compose-v6.yml @@ -15,10 +15,15 @@ services: JWT_SECRET: ${JWT_SECRET} SERVER_ADDR: :6365 TZ: Asia/Shanghai + FLUX_VERSION: ${FLUX_VERSION:-dev} + PANEL_DEPLOY_DIR: /opt/flvx-panel + PANEL_BACKEND_CONTAINER: flux-panel-backend ports: - "${BACKEND_PORT}:6365" volumes: - sqlite_data:/app/data + - /var/run/docker.sock:/var/run/docker.sock + - ./:/opt/flvx-panel networks: - gost-network stop_grace_period: 30s diff --git a/go-backend/Dockerfile b/go-backend/Dockerfile index 7f2a825..3a724b9 100644 --- a/go-backend/Dockerfile +++ b/go-backend/Dockerfile @@ -9,10 +9,14 @@ ARG TARGETOS ARG TARGETARCH RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld +FROM docker:27-cli AS dockercli + FROM debian:bookworm-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/* COPY --from=builder /out/paneld /app/paneld +COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker +COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose ENV SERVER_ADDR=:6365 EXPOSE 6365 diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 1d54d60..c434b04 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -46,6 +46,7 @@ type Handler struct { jobsWG sync.WaitGroup upgradeMu sync.Mutex + systemUpgradeMu sync.Mutex pendingUpgradeRedeploy map[int64]struct{} nodeOnlineRedeployAt map[int64]time.Time nodeOnlineRedeployQueued map[int64]struct{} @@ -156,6 +157,9 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/config/update", h.updateConfigs) mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig) mux.HandleFunc("/api/v1/system/storage", h.storageSummary) + mux.HandleFunc("/api/v1/system/version", h.systemVersion) + mux.HandleFunc("/api/v1/system/check-updates", h.systemCheckUpdates) + mux.HandleFunc("/api/v1/system/upgrade", h.systemUpgrade) mux.HandleFunc("/api/v1/license/activate", h.licenseActivate) mux.HandleFunc("/api/v1/backup/export", h.backupExport) mux.HandleFunc("/api/v1/backup/import", h.backupImport) diff --git a/go-backend/internal/http/handler/system_upgrade.go b/go-backend/internal/http/handler/system_upgrade.go new file mode 100644 index 0000000..f085b5d --- /dev/null +++ b/go-backend/internal/http/handler/system_upgrade.go @@ -0,0 +1,564 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "go-backend/internal/http/response" +) + +const ( + panelDeployDirEnv = "PANEL_DEPLOY_DIR" + panelBackendContainerEnv = "PANEL_BACKEND_CONTAINER" + defaultPanelDeployDir = "/opt/flvx-panel" + defaultPanelBackendName = "flux-panel-backend" + dockerSocketPath = "/var/run/docker.sock" + maxSystemUpgradeComposeAssetBytes = 1 << 20 + systemUpgradeMessage = "升级 helper 已启动,面板服务将短暂重启" + systemUpgradeConflictError = "已有面板升级任务执行中" +) + +var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) +var enableIPv6ComposePattern = regexp.MustCompile(`(?im)^\s*enable_ipv6\s*:\s*['"]?true['"]?\s*(?:#.*)?$`) +var systemUpgradeReleaseBaseURL = githubHTMLBase + +type systemUpgradeExecutor struct { + deployDir string + backendContainer string +} + +type systemUpgradeCapabilityData struct { + Capable bool `json:"capable"` + Reasons []string `json:"reasons"` + DeployDir string `json:"deployDir"` + BackendContainer string `json:"backendContainer"` +} + +type systemUpgradeReleaseData struct { + Version string `json:"version"` + Name string `json:"name"` + PublishedAt string `json:"publishedAt"` + Prerelease bool `json:"prerelease"` + Channel string `json:"channel"` +} + +type systemUpgradeVersionData struct { + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + HasUpdate bool `json:"hasUpdate"` + Channel string `json:"channel"` + Reason string `json:"reason,omitempty"` + Capability systemUpgradeCapabilityData `json:"capability"` +} + +type systemUpgradeCheckData struct { + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + HasUpdate bool `json:"hasUpdate"` + Channel string `json:"channel"` + Capability systemUpgradeCapabilityData `json:"capability"` + Releases []systemUpgradeReleaseData `json:"releases"` +} + +type systemUpgradeRunData struct { + Version string `json:"version"` + Channel string `json:"channel"` + ComposeAsset string `json:"composeAsset"` + HelperContainer string `json:"helperContainer"` + BackendImageID string `json:"backendImageId"` + Message string `json:"message"` +} + +type systemUpgradeRequest struct { + Version string `json:"version"` + Channel string `json:"channel"` +} + +func newSystemUpgradeExecutor() *systemUpgradeExecutor { + deployDir := strings.TrimSpace(os.Getenv(panelDeployDirEnv)) + if deployDir == "" { + deployDir = defaultPanelDeployDir + } + backendContainer := strings.TrimSpace(os.Getenv(panelBackendContainerEnv)) + if backendContainer == "" { + backendContainer = defaultPanelBackendName + } + return &systemUpgradeExecutor{deployDir: deployDir, backendContainer: backendContainer} +} + +func currentPanelVersion() string { + version := strings.TrimSpace(os.Getenv("FLUX_VERSION")) + if version == "" { + return "dev" + } + return version +} + +func validateBackendContainerName(value string) error { + if value == "" { + return fmt.Errorf("backend container name is empty") + } + if !safeBackendContainerPattern.MatchString(value) { + return fmt.Errorf("unsafe backend container name: %s", value) + } + return nil +} + +func validateUpgradeVersion(value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("upgrade version is empty") + } + for _, r := range value { + if r < 0x20 || r == 0x7f { + return fmt.Errorf("unsafe upgrade version: contains control character") + } + } + return nil +} + +func (e *systemUpgradeExecutor) composePath() string { + return filepath.Join(e.deployDir, "docker-compose.yml") +} +func (e *systemUpgradeExecutor) envPath() string { return filepath.Join(e.deployDir, ".env") } + +func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCapabilityData { + reasons := make([]string, 0) + if !filepath.IsAbs(e.deployDir) { + reasons = append(reasons, "部署目录必须是绝对路径") + } + if err := validateBackendContainerName(e.backendContainer); err != nil { + reasons = append(reasons, err.Error()) + } + if out, err := exec.CommandContext(ctx, "docker", "--version").CombinedOutput(); err != nil { + reasons = append(reasons, fmt.Sprintf("docker CLI不可用: %v: %s", err, strings.TrimSpace(string(out)))) + } + if info, err := os.Stat(dockerSocketPath); err != nil { + reasons = append(reasons, "docker socket不可用: "+err.Error()) + } else if info.IsDir() { + reasons = append(reasons, "docker socket路径不是文件") + } + if info, err := os.Stat(e.composePath()); err != nil { + reasons = append(reasons, "部署docker-compose.yml不可用: "+err.Error()) + } else if info.IsDir() { + reasons = append(reasons, "部署docker-compose.yml不是文件") + } + if info, err := os.Stat(e.envPath()); err != nil { + reasons = append(reasons, "部署.env不可用: "+err.Error()) + } else if info.IsDir() { + reasons = append(reasons, "部署.env不是文件") + } + if out, err := exec.CommandContext(ctx, "docker", "compose", "version").CombinedOutput(); err != nil { + reasons = append(reasons, fmt.Sprintf("docker compose不可用: %v: %s", err, strings.TrimSpace(string(out)))) + } + if _, err := e.currentBackendImage(ctx); err != nil { + reasons = append(reasons, err.Error()) + } + + return systemUpgradeCapabilityData{ + Capable: len(reasons) == 0, + Reasons: reasons, + DeployDir: e.deployDir, + BackendContainer: e.backendContainer, + } +} + +func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string { + if enableIPv6ComposePattern.Match(current) { + return "docker-compose-v6.yml" + } + return "docker-compose-v4.yml" +} + +func (e *systemUpgradeExecutor) helperScript() string { + return strings.Join([]string{ + "set -eu", + `cd "$PANEL_DEPLOY_DIR"`, + "docker compose pull backend frontend", + "sleep 5", + "docker compose up -d backend frontend", + }, "\n") +} + +func (e *systemUpgradeExecutor) buildHelperRunArgs(imageID, helperName string) ([]string, error) { + if err := validateBackendContainerName(e.backendContainer); err != nil { + return nil, err + } + return []string{ + "run", "-d", "--rm", "--name", helperName, + "--volumes-from", e.backendContainer, + "-v", dockerSocketPath + ":" + dockerSocketPath, + "-e", panelDeployDirEnv + "=" + e.deployDir, + "--entrypoint", "/bin/sh", imageID, + "-c", e.helperScript(), + }, nil +} + +func (e *systemUpgradeExecutor) updateEnvVersion(envPath, version string) error { + if err := validateUpgradeVersion(version); err != nil { + return err + } + mode, err := fileModeOrDefault(envPath, 0o600) + if err != nil { + return err + } + data, err := os.ReadFile(envPath) + if err != nil { + return err + } + lines := strings.Split(string(data), "\n") + replaced := false + for i, line := range lines { + if strings.HasPrefix(line, "FLUX_VERSION=") { + lines[i] = "FLUX_VERSION=" + version + replaced = true + } + } + if !replaced { + trimmed := strings.TrimRight(strings.Join(lines, "\n"), "\n") + if trimmed == "" { + trimmed = "FLUX_VERSION=" + version + } else { + trimmed += "\nFLUX_VERSION=" + version + } + return writeFileWithMode(envPath, []byte(trimmed+"\n"), mode) + } + content := strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n" + return writeFileWithMode(envPath, []byte(content), mode) +} + +func (e *systemUpgradeExecutor) backupFile(path string) (string, error) { + mode, err := fileModeOrDefault(path, 0o600) + if err != nil { + return "", err + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + backupPath := path + ".upgrade.bak" + if err := writeFileWithMode(backupPath, data, mode); err != nil { + return "", err + } + return backupPath, nil +} + +func (e *systemUpgradeExecutor) restoreBackup(path string) error { + backupPath := path + ".upgrade.bak" + mode, err := fileModeOrDefault(backupPath, 0o600) + if err != nil { + return err + } + data, err := os.ReadFile(backupPath) + if err != nil { + return err + } + return writeFileWithMode(path, data, mode) +} + +func (e *systemUpgradeExecutor) restoreUpgradeBackups(paths ...string) error { + var errs []string + for _, path := range paths { + if err := e.restoreBackup(path); err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", path, err)) + } + } + if len(errs) > 0 { + return fmt.Errorf("%s", strings.Join(errs, "; ")) + } + return nil +} + +func (e *systemUpgradeExecutor) replaceCompose(path string, data []byte) error { + if len(bytes.TrimSpace(data)) == 0 { + return fmt.Errorf("compose asset is empty") + } + mode, err := fileModeOrDefault(path, 0o644) + if err != nil { + return err + } + return writeFileWithMode(path, data, mode) +} + +func fileModeOrDefault(path string, fallback os.FileMode) (os.FileMode, error) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return fallback, nil + } + return 0, err + } + return info.Mode().Perm(), nil +} + +func writeFileWithMode(path string, data []byte, mode os.FileMode) error { + if err := os.WriteFile(path, data, mode); err != nil { + return err + } + return os.Chmod(path, mode) +} + +func (e *systemUpgradeExecutor) currentBackendImage(ctx context.Context) (string, error) { + if err := validateBackendContainerName(e.backendContainer); err != nil { + return "", err + } + out, err := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", e.backendContainer).CombinedOutput() + if err != nil { + return "", fmt.Errorf("inspect backend image failed: %v: %s", err, strings.TrimSpace(string(out))) + } + imageID := strings.TrimSpace(string(out)) + if imageID == "" { + return "", fmt.Errorf("backend image id is empty") + } + return imageID, nil +} + +func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID, helperName string) (string, error) { + args, err := e.buildHelperRunArgs(imageID, helperName) + if err != nil { + return "", err + } + out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("start helper failed: %v: %s", err, strings.TrimSpace(string(out))) + } + containerID := strings.TrimSpace(string(out)) + if containerID == "" { + containerID = helperName + } + return containerID, nil +} + +func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) { + url := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename) + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("下载%s失败: %v", filename, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("下载%s返回 %d: %s", filename, resp.StatusCode, strings.TrimSpace(string(body))) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxSystemUpgradeComposeAssetBytes+1)) + if err != nil { + return nil, fmt.Errorf("读取%s失败: %v", filename, err) + } + if len(body) > maxSystemUpgradeComposeAssetBytes { + return nil, fmt.Errorf("下载%s过大", filename) + } + if len(bytes.TrimSpace(body)) == 0 { + return nil, fmt.Errorf("下载%s内容为空", filename) + } + return body, nil +} + +func releasesForChannel(releases []githubRelease, channel string) []systemUpgradeReleaseData { + channel = normalizeReleaseChannel(channel) + items := make([]systemUpgradeReleaseData, 0, len(releases)) + for _, r := range releases { + if r.Draft { + continue + } + tag := strings.TrimSpace(r.TagName) + if tag == "" { + continue + } + itemChannel := releaseChannelFromTag(tag) + if itemChannel != channel { + continue + } + items = append(items, systemUpgradeReleaseData{ + Version: tag, + Name: r.Name, + PublishedAt: r.PublishedAt, + Prerelease: itemChannel == releaseChannelDev, + Channel: itemChannel, + }) + } + return items +} + +func decodeSystemUpgradeRequest(r *http.Request, req *systemUpgradeRequest) error { + defer r.Body.Close() + body, err := io.ReadAll(r.Body) + if err != nil { + return err + } + if len(bytes.TrimSpace(body)) == 0 { + return nil + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + return decoder.Decode(req) +} + +func systemUpgradeVersionResponse(current, channel, latest string, lookupErr error, capability systemUpgradeCapabilityData) systemUpgradeVersionData { + data := systemUpgradeVersionData{ + CurrentVersion: current, + LatestVersion: latest, + HasUpdate: latest != "" && latest != current, + Channel: channel, + Capability: capability, + } + if lookupErr != nil { + data.LatestVersion = "" + data.HasUpdate = false + data.Reason = lookupErr.Error() + } + return data +} + +func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + channel := releaseChannelStable + current := currentPanelVersion() + exec := newSystemUpgradeExecutor() + capability := exec.capability(r.Context()) + latest, err := resolveLatestReleaseByChannel(channel) + response.WriteJSON(w, response.OK(systemUpgradeVersionResponse(current, channel, latest, err, capability))) +} + +func (h *Handler) systemCheckUpdates(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req systemUpgradeRequest + if err := decodeSystemUpgradeRequest(r, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + channel := normalizeReleaseChannel(req.Channel) + current := currentPanelVersion() + exec := newSystemUpgradeExecutor() + capability := exec.capability(r.Context()) + + githubReleases, err := fetchGitHubReleases(50) + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err))) + return + } + releases := releasesForChannel(githubReleases, channel) + latest := "" + if len(releases) > 0 { + latest = releases[0].Version + } + response.WriteJSON(w, response.OK(systemUpgradeCheckData{ + CurrentVersion: current, + LatestVersion: latest, + HasUpdate: latest != "" && latest != current, + Channel: channel, + Capability: capability, + Releases: releases, + })) +} + +func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.systemUpgradeMu.TryLock() { + response.WriteJSON(w, response.ErrDefault(systemUpgradeConflictError)) + return + } + defer h.systemUpgradeMu.Unlock() + + var req systemUpgradeRequest + if err := decodeSystemUpgradeRequest(r, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + channel := normalizeReleaseChannel(req.Channel) + version := strings.TrimSpace(req.Version) + if version == "" { + var err error + version, err = resolveLatestReleaseByChannel(channel) + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err))) + return + } + } + + exec := newSystemUpgradeExecutor() + capability := exec.capability(r.Context()) + if !capability.Capable { + response.WriteJSON(w, response.ErrDefault("当前环境不支持面板自升级: "+strings.Join(capability.Reasons, "; "))) + return + } + imageID, err := exec.currentBackendImage(r.Context()) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + composePath := exec.composePath() + envPath := exec.envPath() + composeData, err := os.ReadFile(composePath) + if err != nil { + response.WriteJSON(w, response.Err(-2, "读取compose失败: "+err.Error())) + return + } + composeAsset := exec.selectComposeAsset(composeData) + newCompose, err := h.downloadReleaseAsset(version, composeAsset) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if _, err := exec.backupFile(composePath); err != nil { + response.WriteJSON(w, response.Err(-2, "备份compose失败: "+err.Error())) + return + } + if _, err := exec.backupFile(envPath); err != nil { + response.WriteJSON(w, response.Err(-2, "备份.env失败: "+err.Error())) + return + } + if err := exec.replaceCompose(composePath, newCompose); err != nil { + if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil { + err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr) + } + response.WriteJSON(w, response.Err(-2, "替换compose失败: "+err.Error())) + return + } + if err := exec.updateEnvVersion(envPath, version); err != nil { + if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil { + err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr) + } + response.WriteJSON(w, response.Err(-2, "更新版本配置失败: "+err.Error())) + return + } + helperName := fmt.Sprintf("flvx-upgrade-helper-%d", time.Now().Unix()) + helperContainer, err := exec.startHelper(r.Context(), imageID, helperName) + if err != nil { + if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil { + err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr) + } + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OK(systemUpgradeRunData{ + Version: version, + Channel: channel, + ComposeAsset: composeAsset, + HelperContainer: helperContainer, + BackendImageID: imageID, + Message: systemUpgradeMessage, + })) +} diff --git a/go-backend/internal/http/handler/system_upgrade_test.go b/go-backend/internal/http/handler/system_upgrade_test.go new file mode 100644 index 0000000..b6df615 --- /dev/null +++ b/go-backend/internal/http/handler/system_upgrade_test.go @@ -0,0 +1,398 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestSelectComposeAssetUsesIPv6Template(t *testing.T) { + exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"} + compose := []byte("networks:\n gost-network:\n enable_ipv6: true\n") + + if got := exec.selectComposeAsset(compose); got != "docker-compose-v6.yml" { + t.Fatalf("selectComposeAsset() = %q, want %q", got, "docker-compose-v6.yml") + } +} + +func TestDownloadReleaseAssetUsesDirectReleaseURL(t *testing.T) { + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte("services:\n backend:\n image: test\n")) + })) + defer server.Close() + + originalBase := systemUpgradeReleaseBaseURL + systemUpgradeReleaseBaseURL = server.URL + t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase }) + + h := &Handler{} + data, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml") + if err != nil { + t.Fatalf("downloadReleaseAsset() error = %v", err) + } + if !strings.Contains(string(data), "backend") { + t.Fatalf("downloadReleaseAsset() data = %q, want compose data", string(data)) + } + + wantPath := "/" + githubRepo + "/releases/download/2.1.9/docker-compose-v4.yml" + if gotPath != wantPath { + t.Fatalf("download path = %q, want %q", gotPath, wantPath) + } +} + +func TestDownloadReleaseAssetRejectsOversizedBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(bytes.Repeat([]byte("a"), maxSystemUpgradeComposeAssetBytes+1)) + })) + defer server.Close() + + originalBase := systemUpgradeReleaseBaseURL + systemUpgradeReleaseBaseURL = server.URL + t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase }) + + h := &Handler{} + _, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml") + if err == nil || !strings.Contains(err.Error(), "过大") { + t.Fatalf("downloadReleaseAsset() error = %v, want oversized error", err) + } +} + +func TestSelectComposeAssetUsesIPv6TemplateForYAMLVariants(t *testing.T) { + exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"} + for _, compose := range [][]byte{ + []byte("networks:\n gost-network:\n enable_ipv6:true\n"), + []byte("networks:\n gost-network:\n enable_ipv6: True\n"), + []byte("networks:\n gost-network:\n enable_ipv6: \"true\"\n"), + []byte("networks:\n gost-network:\n enable_ipv6: 'true'\n"), + []byte("networks:\n gost-network:\n enable_ipv6: true # comment\n"), + } { + if got := exec.selectComposeAsset(compose); got != "docker-compose-v6.yml" { + t.Fatalf("selectComposeAsset(%q) = %q, want %q", string(compose), got, "docker-compose-v6.yml") + } + } +} + +func TestSelectComposeAssetFallsBackToIPv4Template(t *testing.T) { + exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"} + compose := []byte("services:\n backend:\n image: test\n") + + if got := exec.selectComposeAsset(compose); got != "docker-compose-v4.yml" { + t.Fatalf("selectComposeAsset() = %q, want %q", got, "docker-compose-v4.yml") + } +} + +func TestUpdateEnvVersionReplacesExistingValue(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"} + if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil { + t.Fatalf("updateEnvVersion() error = %v", err) + } + + data, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + want := "FLUX_VERSION=2.1.9\nJWT_SECRET=test\n" + if string(data) != want { + t.Fatalf("env content = %q, want %q", string(data), want) + } +} + +func TestUpdateEnvVersionAppendsMissingValue(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"} + if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil { + t.Fatalf("updateEnvVersion() error = %v", err) + } + + data, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + want := "JWT_SECRET=test\nFLUX_VERSION=2.1.9\n" + if string(data) != want { + t.Fatalf("env content = %q, want %q", string(data), want) + } +} + +func TestUpdateEnvVersionRejectsUnsafeValue(t *testing.T) { + for _, version := range []string{"", "2.1.9\nJWT_SECRET=bad", "2.1.9\rbad", "2.1.9\x00bad", "2.1.9\x1fbad"} { + t.Run(version, func(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + original := []byte("JWT_SECRET=test\n") + if err := os.WriteFile(envPath, original, 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"} + if err := exec.updateEnvVersion(envPath, version); err == nil { + t.Fatal("expected unsafe version to fail validation") + } + + data, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(data) != string(original) { + t.Fatalf("env content changed to %q, want %q", string(data), string(original)) + } + }) + } +} + +func TestUpdateEnvVersionAcceptsVersionLabels(t *testing.T) { + for _, version := range []string{"2.1.9", "2.1.9-beta14", "v-test"} { + t.Run(version, func(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"} + if err := exec.updateEnvVersion(envPath, version); err != nil { + t.Fatalf("updateEnvVersion() error = %v", err) + } + }) + } +} + +func TestUpdateEnvVersionPreservesFileMode(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"} + if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil { + t.Fatalf("updateEnvVersion() error = %v", err) + } + + info, err := os.Stat(envPath) + if err != nil { + t.Fatalf("Stat() error = %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("env mode = %o, want 0600", got) + } +} + +func TestValidateBackendContainerNameRejectsUnsafeValue(t *testing.T) { + if err := validateBackendContainerName("flux-panel-backend;rm -rf /"); err == nil { + t.Fatal("expected unsafe container name to fail validation") + } +} + +func TestBuildHelperRunArgsUsesDetachedContainer(t *testing.T) { + exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"} + args, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper") + if err != nil { + t.Fatalf("buildHelperRunArgs() error = %v", err) + } + want := []string{ + "run", "-d", "--rm", "--name", "flvx-upgrade-helper", + "--volumes-from", "flux-panel-backend", + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel", + "--entrypoint", "/bin/sh", "sha256:abc", + "-c", exec.helperScript(), + } + + if !reflect.DeepEqual(args, want) { + t.Fatalf("buildHelperRunArgs() = %#v, want %#v", args, want) + } +} + +func TestBuildHelperRunArgsRejectsUnsafeBackendContainer(t *testing.T) { + exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend;rm -rf /"} + if _, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper"); err == nil { + t.Fatal("expected unsafe backend container name to fail validation") + } +} + +func TestSystemVersionRejectsWrongMethod(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest(http.MethodGet, "/api/v1/system/version", nil) + rr := httptest.NewRecorder() + + h.systemVersion(rr, req) + + if !strings.Contains(rr.Body.String(), "请求失败") { + t.Fatalf("expected wrong-method response, got %s", rr.Body.String()) + } +} + +func TestSystemUpgradeRejectsConcurrentRequests(t *testing.T) { + h := &Handler{} + h.systemUpgradeMu.Lock() + defer h.systemUpgradeMu.Unlock() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable"}`)) + rr := httptest.NewRecorder() + + h.systemUpgrade(rr, req) + + if !strings.Contains(rr.Body.String(), systemUpgradeConflictError) { + t.Fatalf("expected conflict message, got %s", rr.Body.String()) + } +} + +func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) { + dir := t.TempDir() + composePath := filepath.Join(dir, "docker-compose.yml") + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(composePath, []byte("services:\n backend:\n image: test\n"), 0o644); err != nil { + t.Fatalf("WriteFile() compose error = %v", err) + } + if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil { + t.Fatalf("WriteFile() env error = %v", err) + } + + fakeDockerDir := t.TempDir() + fakeDockerPath := filepath.Join(fakeDockerDir, "docker") + fakeDockerScript := "#!/bin/sh\ncase \"$1\" in\n --version)\n echo 'Docker version 27.0.0'\n exit 0\n ;;&\n compose)\n if [ \"$2\" = version ]; then\n echo 'Docker Compose version v2.33.0'\n exit 0\n fi\n exit 0\n ;;&\n inspect)\n echo 'No such object: flux-panel-backend' >&2\n exit 1\n ;;&\n *)\n exit 0\n ;;&\n esac\n" + if err := os.WriteFile(fakeDockerPath, []byte(fakeDockerScript), 0o755); err != nil { + t.Fatalf("WriteFile() fake docker error = %v", err) + } + t.Setenv("PATH", fakeDockerDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(panelDeployDirEnv, dir) + t.Setenv(panelBackendContainerEnv, "flux-panel-backend") + + h := &Handler{} + req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable"}`)) + rr := httptest.NewRecorder() + + h.systemUpgrade(rr, req) + + if !strings.Contains(rr.Body.String(), "当前环境不支持面板自升级") { + t.Fatalf("expected fail-fast capability error, got %s", rr.Body.String()) + } + if _, err := os.Stat(composePath + ".upgrade.bak"); !os.IsNotExist(err) { + t.Fatalf("expected no compose backup, got err=%v", err) + } + if _, err := os.Stat(envPath + ".upgrade.bak"); !os.IsNotExist(err) { + t.Fatalf("expected no env backup, got err=%v", err) + } + composeData, err := os.ReadFile(composePath) + if err != nil { + t.Fatalf("ReadFile() compose error = %v", err) + } + if string(composeData) != "services:\n backend:\n image: test\n" { + t.Fatalf("compose mutated unexpectedly: %q", string(composeData)) + } + envData, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("ReadFile() env error = %v", err) + } + if string(envData) != "FLUX_VERSION=2.1.8\nJWT_SECRET=test\n" { + t.Fatalf("env mutated unexpectedly: %q", string(envData)) + } +} + +func TestUpgradeBackupUsesStablePathAndRestoreRestoresOriginal(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "docker-compose.yml") + if err := os.WriteFile(path, []byte("original"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"} + backupPath, err := exec.backupFile(path) + if err != nil { + t.Fatalf("backupFile() error = %v", err) + } + if backupPath != path+".upgrade.bak" { + t.Fatalf("backup path = %q, want %q", backupPath, path+".upgrade.bak") + } + if err := os.WriteFile(path, []byte("mutated"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + if err := exec.restoreBackup(path); err != nil { + t.Fatalf("restoreBackup() error = %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(data) != "original" { + t.Fatalf("restored content = %q, want original", string(data)) + } +} + +func TestRestoreBackupPreservesOriginalFileMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + if err := os.WriteFile(path, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"} + if _, err := exec.backupFile(path); err != nil { + t.Fatalf("backupFile() error = %v", err) + } + if err := os.Remove(path); err != nil { + t.Fatalf("Remove() error = %v", err) + } + if err := exec.restoreBackup(path); err != nil { + t.Fatalf("restoreBackup() error = %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat() error = %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("restored mode = %o, want 0600", got) + } +} + +func TestDecodeSystemUpgradeRequestRejectsTruncatedJSON(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader(`{"channel":"stable"`)) + var payload systemUpgradeRequest + + if err := decodeSystemUpgradeRequest(req, &payload); err == nil { + t.Fatal("expected truncated JSON to be rejected") + } +} + +func TestDecodeSystemUpgradeRequestAllowsEmptyBody(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader("")) + var payload systemUpgradeRequest + + if err := decodeSystemUpgradeRequest(req, &payload); err != nil { + t.Fatalf("expected empty body to be accepted, got %v", err) + } +} + +func TestSystemUpgradeVersionDataSurfacesLookupFailureReason(t *testing.T) { + data, err := json.Marshal(systemUpgradeVersionData{Reason: "GitHub unavailable"}) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if !strings.Contains(string(data), `"reason":"GitHub unavailable"`) { + t.Fatalf("expected reason field in JSON, got %s", string(data)) + } +} diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index df70b4c..8c42f9d 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -42,6 +42,9 @@ import type { MonitorAccessApiData, TunnelQualityApiItem, StorageSummaryApiData, + SystemUpgradeCheckApiData, + SystemUpgradeRunApiData, + SystemUpgradeVersionApiData, } from "./types"; import axios from "axios"; @@ -258,6 +261,24 @@ export const updateConfig = (name: string, value: string) => export const getStorageSummary = () => Network.get("/system/storage"); +export const getSystemUpgradeVersion = () => + Network.post("/system/version"); + +export const checkSystemUpgrade = (channel: ReleaseChannel = "stable") => + Network.post("/system/check-updates", { + channel, + }); + +export const runSystemUpgrade = ( + version?: string, + channel: ReleaseChannel = "stable", +) => + Network.post( + "/system/upgrade", + { version: version || "", channel }, + { timeout: 60 * 1000 }, + ); + export const activateLicense = (licenseKey: string) => Network.post("/license/activate", { license_key: licenseKey }); diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index cf9cddf..7ded022 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -200,6 +200,14 @@ export interface NodeReleaseApiItem { channel: "stable" | "dev"; } +export interface SystemUpgradeReleaseApiItem { + version: string; + name: string; + publishedAt: string; + prerelease: boolean; + channel: "stable" | "dev"; +} + export interface UserPackageInfoApiData { userInfo: { flow: number; @@ -484,6 +492,36 @@ export interface StorageSummaryApiData { databaseSizeText: string; } +export interface SystemUpgradeCapabilityApiData { + capable: boolean; + reasons: string[]; + deployDir: string; + backendContainer: string; +} + +export interface SystemUpgradeVersionApiData { + currentVersion: string; + latestVersion: string; + hasUpdate: boolean; + channel: "stable" | "dev"; + reason?: string; + capability: SystemUpgradeCapabilityApiData; +} + +export interface SystemUpgradeCheckApiData + extends SystemUpgradeVersionApiData { + releases: SystemUpgradeReleaseApiItem[]; +} + +export interface SystemUpgradeRunApiData { + version: string; + channel: "stable" | "dev"; + composeAsset: string; + helperContainer: string; + backendImageId: string; + message: string; +} + export interface MonitorNodeApiItem { id: number; inx: number; diff --git a/vite-frontend/src/pages/config.tsx b/vite-frontend/src/pages/config.tsx index 0b9d9a2..454835d 100644 --- a/vite-frontend/src/pages/config.tsx +++ b/vite-frontend/src/pages/config.tsx @@ -27,8 +27,17 @@ import { getAnnouncement, updateAnnouncement, getStorageSummary, + getSystemUpgradeVersion, + checkSystemUpgrade, + runSystemUpgrade, type AnnouncementData, } from "@/api"; +import type { + SystemUpgradeCheckApiData, + SystemUpgradeRunApiData, + SystemUpgradeReleaseApiItem, + SystemUpgradeVersionApiData, +} from "@/api/types"; import { BackIcon, SettingsIcon } from "@/components/icons"; import { ThemeSettings } from "@/components/theme-settings"; import { isAdmin } from "@/utils/auth"; @@ -292,6 +301,19 @@ export default function ConfigPage() { const [updateChannel, setUpdateChannel] = useState( getUpdateReleaseChannel(), ); + const [systemUpgradeInfo, setSystemUpgradeInfo] = + useState(null); + const [systemUpgradeChecking, setSystemUpgradeChecking] = useState(false); + const [systemUpgradeExecuting, setSystemUpgradeExecuting] = useState(false); + const [systemUpgradeLoading, setSystemUpgradeLoading] = useState(true); + const [systemUpgradeModalOpen, setSystemUpgradeModalOpen] = useState(false); + const [systemUpgradeReleases, setSystemUpgradeReleases] = useState< + SystemUpgradeReleaseApiItem[] + >([]); + const [systemUpgradeCheckedChannel, setSystemUpgradeCheckedChannel] = + useState(null); + const [systemUpgradeSelectedVersion, setSystemUpgradeSelectedVersion] = + useState(""); const [previewLoadFailed, setPreviewLoadFailed] = useState< Partial> >({}); @@ -299,6 +321,20 @@ export default function ConfigPage() { Partial> >({}); const [storageSummary, setStorageSummary] = useState("加载中..."); + const systemUpgradeReleasesMatchChannel = + systemUpgradeCheckedChannel === updateChannel; + const systemUpgradeHasConfirmedUpdate = Boolean( + systemUpgradeInfo?.hasUpdate && + systemUpgradeReleasesMatchChannel && + systemUpgradeReleases.length > 0, + ); + const canOpenSystemUpgradeModal = Boolean( + systemUpgradeInfo?.capability.capable && + systemUpgradeHasConfirmedUpdate && + !systemUpgradeLoading && + !systemUpgradeChecking && + !systemUpgradeExecuting, + ); const canGoBack = typeof window !== "undefined" && @@ -373,11 +409,36 @@ export default function ConfigPage() { } }; + const loadSystemUpgradeInfo = async (channel = updateChannel) => { + setSystemUpgradeLoading(true); + try { + const response = await getSystemUpgradeVersion(); + + if (response.code === 0 && response.data) { + setSystemUpgradeInfo({ + ...response.data, + channel, + hasUpdate: + response.data.channel === channel ? response.data.hasUpdate : false, + latestVersion: + response.data.channel === channel ? response.data.latestVersion : "", + }); + } else { + setSystemUpgradeInfo(null); + } + } catch { + setSystemUpgradeInfo(null); + } finally { + setSystemUpgradeLoading(false); + } + }; + useEffect(() => { const timer = setTimeout(() => { loadConfigs(initialConfigs); loadAnnouncement(); loadStorageSummary(); + void loadSystemUpgradeInfo(); }, 100); return () => clearTimeout(timer); @@ -417,11 +478,92 @@ export default function ConfigPage() { const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => { setUpdateChannel(channel); setUpdateReleaseChannel(channel); + setSystemUpgradeSelectedVersion(""); + setSystemUpgradeReleases([]); + setSystemUpgradeCheckedChannel(null); + void loadSystemUpgradeInfo(channel); toast.success( `更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`, ); }; + const handleCheckSystemUpgrade = async () => { + const channel = updateChannel; + + setSystemUpgradeChecking(true); + try { + const response = await checkSystemUpgrade(channel); + + if (response.code === 0 && response.data) { + const data = response.data as SystemUpgradeCheckApiData; + + setSystemUpgradeInfo(data); + setSystemUpgradeReleases(data.releases || []); + setSystemUpgradeCheckedChannel(channel); + setSystemUpgradeSelectedVersion(""); + if (data.latestVersion && !data.hasUpdate) { + toast.success("当前已是最新版本"); + + return false; + } + toast.success( + data.latestVersion + ? `已检查到最新版本 ${data.latestVersion}` + : "未获取到可用版本", + ); + + return Boolean(data.capability.capable && data.hasUpdate && data.releases?.length); + } else { + setSystemUpgradeReleases([]); + setSystemUpgradeCheckedChannel(null); + toast.error(response.msg || "检查更新失败"); + } + } catch { + setSystemUpgradeReleases([]); + setSystemUpgradeCheckedChannel(null); + toast.error("检查更新失败,请重试"); + } finally { + setSystemUpgradeChecking(false); + } + + return false; + }; + + const handleOpenSystemUpgradeModal = async () => { + if (!canOpenSystemUpgradeModal) { + const checked = await handleCheckSystemUpgrade(); + + if (!checked) { + return; + } + } + setSystemUpgradeModalOpen(true); + }; + + const handleConfirmSystemUpgrade = async () => { + setSystemUpgradeExecuting(true); + try { + const response = await runSystemUpgrade( + systemUpgradeSelectedVersion || undefined, + updateChannel, + ); + + if (response.code === 0 && response.data) { + const data = response.data as SystemUpgradeRunApiData; + + setSystemUpgradeModalOpen(false); + setSystemUpgradeSelectedVersion(""); + toast.success(data.message || "升级已触发,请稍后刷新页面"); + } else { + toast.error(response.msg || "面板升级失败"); + } + } catch { + toast.error("面板升级失败,请重试"); + } finally { + setSystemUpgradeExecuting(false); + } + }; + const handleActivateLicense = async () => { if (!licenseKeyInput.trim()) { toast.error("请输入有效的商业授权码"); @@ -1329,6 +1471,7 @@ export default function ConfigPage() { 0 + ? "留空时自动选择最新版本" + : "请先检查当前通道更新" + } + selectedKeys={ + systemUpgradeSelectedVersion + ? [systemUpgradeSelectedVersion] + : [] + } + size="md" + variant="bordered" + onSelectionChange={(keys) => { + const selected = Array.from(keys)[0] as string | undefined; + + setSystemUpgradeSelectedVersion(selected || ""); + }} + > + {(systemUpgradeReleasesMatchChannel + ? systemUpgradeReleases + : [] + ).map((release) => ( + + {release.name || release.version} + + ))} + + + + )} + +
+ + +
+ +
+ + + + )} + + + {/* Floating Save Button (FAB) */} {hasChanges && ( diff --git a/vite-frontend/src/shadcn-bridge/heroui/modal.tsx b/vite-frontend/src/shadcn-bridge/heroui/modal.tsx index 113161d..72c68af 100644 --- a/vite-frontend/src/shadcn-bridge/heroui/modal.tsx +++ b/vite-frontend/src/shadcn-bridge/heroui/modal.tsx @@ -160,7 +160,6 @@ export function ModalContent({ showCloseButton={false} {...props} > - Modal Dialog {renderedChildren} ); @@ -173,15 +172,17 @@ export function ModalHeader({ const context = useModalContext(); return ( -
+ +
+ ); } diff --git a/vite-frontend/src/shadcn-bridge/heroui/select.tsx b/vite-frontend/src/shadcn-bridge/heroui/select.tsx index 121e9e6..5fb582d 100644 --- a/vite-frontend/src/shadcn-bridge/heroui/select.tsx +++ b/vite-frontend/src/shadcn-bridge/heroui/select.tsx @@ -23,6 +23,7 @@ interface ClassNameMap { } export interface SelectProps extends FieldMetaProps { + "aria-label"?: string; children?: React.ReactNode | ((item: T) => React.ReactNode); className?: string; classNames?: ClassNameMap; @@ -146,6 +147,7 @@ function textSizeClass(size: SelectProps["size"]) { } export function Select({ + "aria-label": ariaLabel, children, className, classNames, @@ -365,6 +367,7 @@ export function Select({ aria-controls={`${generatedId}-listbox`} aria-expanded={isExpanded} aria-haspopup="listbox" + aria-label={label ? undefined : ariaLabel} className={cn( "flex w-full min-w-0 items-center gap-2 overflow-hidden rounded-md border border-input bg-background px-3 py-2 text-left shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring", isDisabled ? "cursor-not-allowed opacity-60" : "", @@ -406,6 +409,7 @@ export function Select({ )} disabled={isDisabled} id={generatedId} + aria-label={label ? undefined : ariaLabel} required={isRequired} value={singleValue} onChange={handleChange}