mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
feat: add panel self-upgrade workflow (#492)
This commit is contained in:
@@ -15,10 +15,15 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
SERVER_ADDR: :6365
|
SERVER_ADDR: :6365
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
|
FLUX_VERSION: ${FLUX_VERSION:-dev}
|
||||||
|
PANEL_DEPLOY_DIR: /opt/flvx-panel
|
||||||
|
PANEL_BACKEND_CONTAINER: flux-panel-backend
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT}:6365"
|
- "${BACKEND_PORT}:6365"
|
||||||
volumes:
|
volumes:
|
||||||
- sqlite_data:/app/data
|
- sqlite_data:/app/data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./:/opt/flvx-panel
|
||||||
networks:
|
networks:
|
||||||
- gost-network
|
- gost-network
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
SERVER_ADDR: :6365
|
SERVER_ADDR: :6365
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
|
FLUX_VERSION: ${FLUX_VERSION:-dev}
|
||||||
|
PANEL_DEPLOY_DIR: /opt/flvx-panel
|
||||||
|
PANEL_BACKEND_CONTAINER: flux-panel-backend
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT}:6365"
|
- "${BACKEND_PORT}:6365"
|
||||||
volumes:
|
volumes:
|
||||||
- sqlite_data:/app/data
|
- sqlite_data:/app/data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./:/opt/flvx-panel
|
||||||
networks:
|
networks:
|
||||||
- gost-network
|
- gost-network
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ ARG TARGETOS
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
|
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
|
FROM debian:bookworm-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
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=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
|
ENV SERVER_ADDR=:6365
|
||||||
EXPOSE 6365
|
EXPOSE 6365
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type Handler struct {
|
|||||||
jobsWG sync.WaitGroup
|
jobsWG sync.WaitGroup
|
||||||
|
|
||||||
upgradeMu sync.Mutex
|
upgradeMu sync.Mutex
|
||||||
|
systemUpgradeMu sync.Mutex
|
||||||
pendingUpgradeRedeploy map[int64]struct{}
|
pendingUpgradeRedeploy map[int64]struct{}
|
||||||
nodeOnlineRedeployAt map[int64]time.Time
|
nodeOnlineRedeployAt map[int64]time.Time
|
||||||
nodeOnlineRedeployQueued map[int64]struct{}
|
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", h.updateConfigs)
|
||||||
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
||||||
mux.HandleFunc("/api/v1/system/storage", h.storageSummary)
|
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/license/activate", h.licenseActivate)
|
||||||
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
||||||
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@ import type {
|
|||||||
MonitorAccessApiData,
|
MonitorAccessApiData,
|
||||||
TunnelQualityApiItem,
|
TunnelQualityApiItem,
|
||||||
StorageSummaryApiData,
|
StorageSummaryApiData,
|
||||||
|
SystemUpgradeCheckApiData,
|
||||||
|
SystemUpgradeRunApiData,
|
||||||
|
SystemUpgradeVersionApiData,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -258,6 +261,24 @@ export const updateConfig = (name: string, value: string) =>
|
|||||||
export const getStorageSummary = () =>
|
export const getStorageSummary = () =>
|
||||||
Network.get<StorageSummaryApiData>("/system/storage");
|
Network.get<StorageSummaryApiData>("/system/storage");
|
||||||
|
|
||||||
|
export const getSystemUpgradeVersion = () =>
|
||||||
|
Network.post<SystemUpgradeVersionApiData>("/system/version");
|
||||||
|
|
||||||
|
export const checkSystemUpgrade = (channel: ReleaseChannel = "stable") =>
|
||||||
|
Network.post<SystemUpgradeCheckApiData>("/system/check-updates", {
|
||||||
|
channel,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const runSystemUpgrade = (
|
||||||
|
version?: string,
|
||||||
|
channel: ReleaseChannel = "stable",
|
||||||
|
) =>
|
||||||
|
Network.post<SystemUpgradeRunApiData>(
|
||||||
|
"/system/upgrade",
|
||||||
|
{ version: version || "", channel },
|
||||||
|
{ timeout: 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
export const activateLicense = (licenseKey: string) =>
|
export const activateLicense = (licenseKey: string) =>
|
||||||
Network.post("/license/activate", { license_key: licenseKey });
|
Network.post("/license/activate", { license_key: licenseKey });
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,14 @@ export interface NodeReleaseApiItem {
|
|||||||
channel: "stable" | "dev";
|
channel: "stable" | "dev";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SystemUpgradeReleaseApiItem {
|
||||||
|
version: string;
|
||||||
|
name: string;
|
||||||
|
publishedAt: string;
|
||||||
|
prerelease: boolean;
|
||||||
|
channel: "stable" | "dev";
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserPackageInfoApiData {
|
export interface UserPackageInfoApiData {
|
||||||
userInfo: {
|
userInfo: {
|
||||||
flow: number;
|
flow: number;
|
||||||
@@ -484,6 +492,36 @@ export interface StorageSummaryApiData {
|
|||||||
databaseSizeText: string;
|
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 {
|
export interface MonitorNodeApiItem {
|
||||||
id: number;
|
id: number;
|
||||||
inx: number;
|
inx: number;
|
||||||
|
|||||||
@@ -27,8 +27,17 @@ import {
|
|||||||
getAnnouncement,
|
getAnnouncement,
|
||||||
updateAnnouncement,
|
updateAnnouncement,
|
||||||
getStorageSummary,
|
getStorageSummary,
|
||||||
|
getSystemUpgradeVersion,
|
||||||
|
checkSystemUpgrade,
|
||||||
|
runSystemUpgrade,
|
||||||
type AnnouncementData,
|
type AnnouncementData,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
import type {
|
||||||
|
SystemUpgradeCheckApiData,
|
||||||
|
SystemUpgradeRunApiData,
|
||||||
|
SystemUpgradeReleaseApiItem,
|
||||||
|
SystemUpgradeVersionApiData,
|
||||||
|
} from "@/api/types";
|
||||||
import { BackIcon, SettingsIcon } from "@/components/icons";
|
import { BackIcon, SettingsIcon } from "@/components/icons";
|
||||||
import { ThemeSettings } from "@/components/theme-settings";
|
import { ThemeSettings } from "@/components/theme-settings";
|
||||||
import { isAdmin } from "@/utils/auth";
|
import { isAdmin } from "@/utils/auth";
|
||||||
@@ -292,6 +301,19 @@ export default function ConfigPage() {
|
|||||||
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
||||||
getUpdateReleaseChannel(),
|
getUpdateReleaseChannel(),
|
||||||
);
|
);
|
||||||
|
const [systemUpgradeInfo, setSystemUpgradeInfo] =
|
||||||
|
useState<SystemUpgradeVersionApiData | null>(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<UpdateReleaseChannel | null>(null);
|
||||||
|
const [systemUpgradeSelectedVersion, setSystemUpgradeSelectedVersion] =
|
||||||
|
useState("");
|
||||||
const [previewLoadFailed, setPreviewLoadFailed] = useState<
|
const [previewLoadFailed, setPreviewLoadFailed] = useState<
|
||||||
Partial<Record<BrandPreviewKey, boolean>>
|
Partial<Record<BrandPreviewKey, boolean>>
|
||||||
>({});
|
>({});
|
||||||
@@ -299,6 +321,20 @@ export default function ConfigPage() {
|
|||||||
Partial<Record<BrandPreviewKey, boolean>>
|
Partial<Record<BrandPreviewKey, boolean>>
|
||||||
>({});
|
>({});
|
||||||
const [storageSummary, setStorageSummary] = useState("加载中...");
|
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 =
|
const canGoBack =
|
||||||
typeof window !== "undefined" &&
|
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(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
loadConfigs(initialConfigs);
|
loadConfigs(initialConfigs);
|
||||||
loadAnnouncement();
|
loadAnnouncement();
|
||||||
loadStorageSummary();
|
loadStorageSummary();
|
||||||
|
void loadSystemUpgradeInfo();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
@@ -417,11 +478,92 @@ export default function ConfigPage() {
|
|||||||
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
||||||
setUpdateChannel(channel);
|
setUpdateChannel(channel);
|
||||||
setUpdateReleaseChannel(channel);
|
setUpdateReleaseChannel(channel);
|
||||||
|
setSystemUpgradeSelectedVersion("");
|
||||||
|
setSystemUpgradeReleases([]);
|
||||||
|
setSystemUpgradeCheckedChannel(null);
|
||||||
|
void loadSystemUpgradeInfo(channel);
|
||||||
toast.success(
|
toast.success(
|
||||||
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
|
`更新通道已切换为${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 () => {
|
const handleActivateLicense = async () => {
|
||||||
if (!licenseKeyInput.trim()) {
|
if (!licenseKeyInput.trim()) {
|
||||||
toast.error("请输入有效的商业授权码");
|
toast.error("请输入有效的商业授权码");
|
||||||
@@ -1329,6 +1471,7 @@ export default function ConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
|
aria-label="更新通道"
|
||||||
selectedKeys={[updateChannel]}
|
selectedKeys={[updateChannel]}
|
||||||
size="md"
|
size="md"
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
@@ -1367,6 +1510,163 @@ export default function ConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Divider className="my-2" />
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-xl border border-divider bg-default-50/60 p-4 dark:bg-default-100/10">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
面板自升级
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
检查当前版本、可用发布并在容器环境中触发面板升级。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{systemUpgradeLoading ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-divider bg-background px-4 py-3 text-sm text-default-500">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
正在加载升级状态...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 rounded-lg border border-divider bg-background px-4 py-4 text-sm text-default-700 dark:text-default-300">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-default-500">当前版本</p>
|
||||||
|
<p className="mt-1 font-medium">
|
||||||
|
{systemUpgradeInfo?.currentVersion || "未获取到版本信息"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-default-500">最新版本</p>
|
||||||
|
<p className="mt-1 font-medium">
|
||||||
|
{systemUpgradeInfo?.latestVersion || "未获取到可用版本"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-default-500">当前通道</p>
|
||||||
|
<p className="mt-1 font-medium">
|
||||||
|
{systemUpgradeInfo?.channel === "dev"
|
||||||
|
? "开发版"
|
||||||
|
: systemUpgradeInfo?.channel === "stable"
|
||||||
|
? "稳定版"
|
||||||
|
: updateChannel === "dev"
|
||||||
|
? "开发版"
|
||||||
|
: "稳定版"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-default-500">升级能力</p>
|
||||||
|
<p className="mt-1 font-medium">
|
||||||
|
{systemUpgradeInfo?.capability.capable
|
||||||
|
? "可升级"
|
||||||
|
: "当前不可升级"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-default-500">部署目录</p>
|
||||||
|
<p className="mt-1 break-all font-medium">
|
||||||
|
{systemUpgradeInfo?.capability.deployDir || "未获取到部署目录"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-default-500">后端容器</p>
|
||||||
|
<p className="mt-1 break-all font-medium">
|
||||||
|
{systemUpgradeInfo?.capability.backendContainer ||
|
||||||
|
"未获取到容器信息"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!systemUpgradeInfo?.capability.capable && (
|
||||||
|
<div className="rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-warning-800 dark:border-warning-900/40 dark:bg-warning-950/30 dark:text-warning-200">
|
||||||
|
<p className="text-xs font-medium">当前无法升级</p>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-4 text-xs">
|
||||||
|
{(systemUpgradeInfo?.capability.reasons?.length
|
||||||
|
? systemUpgradeInfo.capability.reasons
|
||||||
|
: ["暂未获取到不可升级原因"]
|
||||||
|
).map((reason) => (
|
||||||
|
<li key={reason}>{reason}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
可用发布版本
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
选择指定版本后执行升级;留空则使用当前通道下最新可用版本。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
aria-label="目标版本"
|
||||||
|
isDisabled={
|
||||||
|
!systemUpgradeReleasesMatchChannel ||
|
||||||
|
systemUpgradeReleases.length === 0 ||
|
||||||
|
systemUpgradeExecuting
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
systemUpgradeReleasesMatchChannel &&
|
||||||
|
systemUpgradeReleases.length > 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) => (
|
||||||
|
<SelectItem
|
||||||
|
key={release.version}
|
||||||
|
description={release.publishedAt || "暂无发布时间"}
|
||||||
|
>
|
||||||
|
{release.name || release.version}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-1 sm:flex-row sm:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="flat"
|
||||||
|
isLoading={systemUpgradeChecking}
|
||||||
|
onPress={handleCheckSystemUpgrade}
|
||||||
|
>
|
||||||
|
检查更新
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isDisabled={
|
||||||
|
!canOpenSystemUpgradeModal
|
||||||
|
}
|
||||||
|
isLoading={systemUpgradeExecuting}
|
||||||
|
onPress={handleOpenSystemUpgradeModal}
|
||||||
|
>
|
||||||
|
立即升级
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-6 border-t border-divider/50 mt-4">
|
<div className="flex justify-end pt-6 border-t border-divider/50 mt-4">
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -1606,6 +1906,62 @@ export default function ConfigPage() {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
backdrop="blur"
|
||||||
|
classNames={{
|
||||||
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||||
|
}}
|
||||||
|
isOpen={systemUpgradeModalOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!systemUpgradeExecuting) {
|
||||||
|
setSystemUpgradeModalOpen(open);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader>确认面板升级</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<div className="space-y-3 text-sm text-default-700 dark:text-default-300">
|
||||||
|
<p>
|
||||||
|
升级过程需要访问 Docker Socket,并会在短时间内中断当前面板服务。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
请确认已经允许面板管理容器与宿主机 Docker 交互,并且可以接受升级期间的临时不可用。
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-warning-800 dark:border-warning-900/40 dark:bg-warning-950/30 dark:text-warning-200">
|
||||||
|
<p className="text-xs font-medium">升级前请确认</p>
|
||||||
|
<ul className="list-disc space-y-1 pl-4 text-xs">
|
||||||
|
<li>Docker Socket 可用且挂载权限正常。</li>
|
||||||
|
<li>当前面板允许短暂停止和重启。</li>
|
||||||
|
<li>已选择正确的更新通道与目标版本。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
isDisabled={systemUpgradeExecuting}
|
||||||
|
variant="light"
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isDisabled={systemUpgradeExecuting}
|
||||||
|
isLoading={systemUpgradeExecuting}
|
||||||
|
onPress={handleConfirmSystemUpgrade}
|
||||||
|
>
|
||||||
|
确认升级
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Floating Save Button (FAB) */}
|
{/* Floating Save Button (FAB) */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ export function ModalContent({
|
|||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">Modal Dialog</DialogTitle>
|
|
||||||
{renderedChildren}
|
{renderedChildren}
|
||||||
</BaseDialogContent>
|
</BaseDialogContent>
|
||||||
);
|
);
|
||||||
@@ -173,15 +172,17 @@ export function ModalHeader({
|
|||||||
const context = useModalContext();
|
const context = useModalContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<DialogTitle asChild>
|
||||||
className={cn(
|
<div
|
||||||
"text-lg font-semibold",
|
className={cn(
|
||||||
context?.classNames?.header,
|
"text-lg font-semibold",
|
||||||
className,
|
context?.classNames?.header,
|
||||||
)}
|
className,
|
||||||
data-slot="modal-header"
|
)}
|
||||||
{...props}
|
data-slot="modal-header"
|
||||||
/>
|
{...props}
|
||||||
|
/>
|
||||||
|
</DialogTitle>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface ClassNameMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectProps<T = unknown> extends FieldMetaProps {
|
export interface SelectProps<T = unknown> extends FieldMetaProps {
|
||||||
|
"aria-label"?: string;
|
||||||
children?: React.ReactNode | ((item: T) => React.ReactNode);
|
children?: React.ReactNode | ((item: T) => React.ReactNode);
|
||||||
className?: string;
|
className?: string;
|
||||||
classNames?: ClassNameMap;
|
classNames?: ClassNameMap;
|
||||||
@@ -146,6 +147,7 @@ function textSizeClass(size: SelectProps["size"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Select<T>({
|
export function Select<T>({
|
||||||
|
"aria-label": ariaLabel,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
classNames,
|
classNames,
|
||||||
@@ -365,6 +367,7 @@ export function Select<T>({
|
|||||||
aria-controls={`${generatedId}-listbox`}
|
aria-controls={`${generatedId}-listbox`}
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
|
aria-label={label ? undefined : ariaLabel}
|
||||||
className={cn(
|
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",
|
"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" : "",
|
isDisabled ? "cursor-not-allowed opacity-60" : "",
|
||||||
@@ -406,6 +409,7 @@ export function Select<T>({
|
|||||||
)}
|
)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
id={generatedId}
|
id={generatedId}
|
||||||
|
aria-label={label ? undefined : ariaLabel}
|
||||||
required={isRequired}
|
required={isRequired}
|
||||||
value={singleValue}
|
value={singleValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|||||||
Reference in New Issue
Block a user