Files
flvx/docs/superpowers/plans/2026-05-04-panel-self-upgrade.md
T

43 KiB

Panel Self-Upgrade Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add an admin-only “panel self-upgrade” flow that checks FLVX GitHub releases and upgrades the backend/frontend Docker Compose deployment from the settings page.

Architecture: Keep the upgrade entrypoint inside go-backend, but do not let the backend container rebuild itself directly. The backend updates the mounted deployment files, discovers its current image ID through Docker, and launches a detached helper container from that same image. The helper container inherits the deployment bind mount and Docker socket, then runs the fixed docker compose pull backend frontend and docker compose up -d backend frontend sequence. The frontend adds a new card to src/pages/config.tsx that shows capability state, checks updates, and triggers the upgrade with a confirmation modal.

Tech Stack: Go 1.25 / net-http handlers / existing release helpers in upgrade.go / Docker CLI via multi-stage Dockerfile copy / React 18 / TypeScript / HeroUI bridge / existing Network.post API client.

Execution Constraints: Do not create a git commit during execution unless the user explicitly asks for one in that implementation session.


File Map

  • Create: go-backend/internal/http/handler/system_upgrade.go Responsibility: system upgrade data types, capability checks, .env updates, compose asset selection, helper-container launch, and HTTP handlers for /api/v1/system/version, /api/v1/system/check-updates, and /api/v1/system/upgrade.
  • Create: go-backend/internal/http/handler/system_upgrade_test.go Responsibility: helper logic unit tests, method-guard tests, and upgrade lock tests.
  • Modify: go-backend/internal/http/handler/handler.go Responsibility: add a dedicated mutex field and register new system upgrade routes.
  • Modify: go-backend/internal/http/handler/upgrade.go Responsibility: reuse existing release-channel helpers and GitHub proxy URL builder from system upgrade code.
  • Modify: go-backend/Dockerfile Responsibility: copy Docker CLI and compose plugin into the runtime image.
  • Modify: docker-compose-v4.yml Responsibility: pass FLUX_VERSION, PANEL_DEPLOY_DIR, PANEL_BACKEND_CONTAINER, mount Docker socket, and mount the deployment directory.
  • Modify: docker-compose-v6.yml Responsibility: same runtime wiring as v4 while preserving IPv6 network config.
  • Modify: vite-frontend/src/api/types.ts Responsibility: add typed response contracts for system upgrade status, release list, and run result.
  • Modify: vite-frontend/src/api/index.ts Responsibility: add getSystemUpgradeVersion, checkSystemUpgrade, and runSystemUpgrade wrappers.
  • Modify: vite-frontend/src/pages/config.tsx Responsibility: add upgrade card, loading state, release list, capability reason, confirmation modal, and upgrade action.

Task 1: Build the backend helper core with unit tests

Files:

  • Create: go-backend/internal/http/handler/system_upgrade.go

  • Create: go-backend/internal/http/handler/system_upgrade_test.go

  • Step 1: Write the failing helper tests

package handler

import (
	"os"
	"path/filepath"
	"reflect"
	"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 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 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 := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper")
	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)
	}
}
  • Step 2: Run the focused backend tests to verify they fail

Run:

go test ./internal/http/handler -run 'Test(SelectComposeAsset|UpdateEnvVersion|ValidateBackendContainerName|BuildHelperRunArgs)' -count=1

Expected: FAIL with errors such as undefined: systemUpgradeExecutor, undefined: validateBackendContainerName, and undefined: (*systemUpgradeExecutor).updateEnvVersion.

  • Step 3: Write the minimal helper implementation

Create go-backend/internal/http/handler/system_upgrade.go with this initial helper core:

package handler

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"time"
)

const (
	panelDeployDirEnv          = "PANEL_DEPLOY_DIR"
	panelBackendContainerEnv   = "PANEL_BACKEND_CONTAINER"
	defaultPanelDeployDir      = "/opt/flvx-panel"
	defaultPanelBackendName    = "flux-panel-backend"
	dockerSocketPath           = "/var/run/docker.sock"
	systemUpgradeMessage       = "升级 helper 已启动,面板服务将短暂重启"
	systemUpgradeConflictError = "已有面板升级任务执行中"
)

var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)

type systemUpgradeExecutor struct {
	deployDir        string
	backendContainer string
}

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 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 (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) selectComposeAsset(current []byte) string {
	if strings.Contains(string(current), "enable_ipv6: true") {
		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 {
	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(),
	}
}

func (e *systemUpgradeExecutor) updateEnvVersion(envPath, version string) error {
	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 os.WriteFile(envPath, []byte(trimmed+"\n"), 0o644)
	}

	content := strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n"
	return os.WriteFile(envPath, []byte(content), 0o644)
}
  • Step 4: Run the focused backend tests again

Run:

go test ./internal/http/handler -run 'Test(SelectComposeAsset|UpdateEnvVersion|ValidateBackendContainerName|BuildHelperRunArgs)' -count=1

Expected: PASS.

  • Step 5: Commit only if the user explicitly requested a commit
git add go-backend/internal/http/handler/system_upgrade.go go-backend/internal/http/handler/system_upgrade_test.go
git commit -m "feat: add panel upgrade helper core"

Task 2: Wire backend handlers, capability checks, and upgrade orchestration

Files:

  • Modify: go-backend/internal/http/handler/handler.go

  • Modify: go-backend/internal/http/handler/upgrade.go

  • Modify: go-backend/internal/http/handler/system_upgrade.go

  • Modify: go-backend/internal/http/handler/system_upgrade_test.go

  • Step 1: Add failing handler tests for method guards and the upgrade lock

Append these tests to go-backend/internal/http/handler/system_upgrade_test.go:

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())
	}
}
  • Step 2: Run the new backend tests to verify they fail

Run:

go test ./internal/http/handler -run 'Test(SystemVersionRejectsWrongMethod|SystemUpgradeRejectsConcurrentRequests)' -count=1

Expected: FAIL with errors such as h.systemVersion undefined, h.systemUpgrade undefined, and Handler has no field or method systemUpgradeMu.

  • Step 3: Implement the system upgrade handlers and orchestration

Update go-backend/internal/http/handler/handler.go so Handler has a dedicated system-upgrade mutex and route registration:

type Handler struct {
	repo        *repo.Repository
	jwtSecret   string
	wsServer    *ws.Server
	metrics     *metrics.IngestionService
	healthCheck *health.Checker

	captchaMu     sync.Mutex
	captchaTokens map[string]int64

	jobsMu      sync.Mutex
	jobsCancel  context.CancelFunc
	jobsStarted bool
	jobsWG      sync.WaitGroup

	systemUpgradeMu         sync.Mutex
	upgradeMu               sync.Mutex
	pendingUpgradeRedeploy  map[int64]struct{}
	nodeOnlineRedeployAt    map[int64]time.Time
	nodeOnlineRedeployQueued map[int64]struct{}
	nodeOnlineRedeploying   map[int64]struct{}

	qualityProber *tunnelQualityProber
}

func (h *Handler) Register(mux *http.ServeMux) {
	// existing handlers...
	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)
	// existing handlers...
}

Extend go-backend/internal/http/handler/system_upgrade.go with the capability/result types and the minimal orchestration functions:

type systemUpgradeCapability struct {
	Capable          bool   `json:"capable"`
	Reason           string `json:"reason,omitempty"`
	DeployDir        string `json:"deployDir,omitempty"`
	ComposeFile      string `json:"composeFile,omitempty"`
	BackendContainer string `json:"backendContainer,omitempty"`
}

type systemUpgradeVersionData struct {
	CurrentVersion   string `json:"currentVersion"`
	Channel          string `json:"channel"`
	LatestVersion    string `json:"latestVersion,omitempty"`
	HasUpdate        bool   `json:"hasUpdate"`
	Capable          bool   `json:"capable"`
	Reason           string `json:"reason,omitempty"`
	DeployDir        string `json:"deployDir,omitempty"`
	ComposeFile      string `json:"composeFile,omitempty"`
	BackendContainer string `json:"backendContainer,omitempty"`
}

type systemUpgradeCheckData struct {
	CurrentVersion   string                 `json:"currentVersion"`
	Channel          string                 `json:"channel"`
	LatestVersion    string                 `json:"latestVersion,omitempty"`
	HasUpdate        bool                   `json:"hasUpdate"`
	Capable          bool                   `json:"capable"`
	Reason           string                 `json:"reason,omitempty"`
	DeployDir        string                 `json:"deployDir,omitempty"`
	ComposeFile      string                 `json:"composeFile,omitempty"`
	BackendContainer string                 `json:"backendContainer,omitempty"`
	Releases         []systemUpgradeReleaseItem `json:"releases"`
}

type systemUpgradeReleaseItem struct {
	Version     string `json:"version"`
	Name        string `json:"name"`
	PublishedAt string `json:"publishedAt"`
	Prerelease  bool   `json:"prerelease"`
	Channel     string `json:"channel"`
}

type systemUpgradeRunData struct {
	Version           string   `json:"version"`
	Message           string   `json:"message"`
	Commands          []string `json:"commands"`
	HelperContainerID string   `json:"helperContainerId,omitempty"`
}

func currentPanelVersion() string {
	version := strings.TrimSpace(os.Getenv("FLUX_VERSION"))
	if version == "" {
		return "dev"
	}
	return version
}

func (e *systemUpgradeExecutor) checkCapability(ctx context.Context) systemUpgradeCapability {
	capability := systemUpgradeCapability{
		DeployDir:        e.deployDir,
		ComposeFile:      e.composePath(),
		BackendContainer: e.backendContainer,
	}

	if e.deployDir == "" || !filepath.IsAbs(e.deployDir) {
		capability.Reason = "PANEL_DEPLOY_DIR 必须是已挂载的绝对路径"
		return capability
	}
	if err := validateBackendContainerName(e.backendContainer); err != nil {
		capability.Reason = err.Error()
		return capability
	}
	if _, err := exec.LookPath("docker"); err != nil {
		capability.Reason = "docker CLI 不可用"
		return capability
	}
	if _, err := os.Stat(dockerSocketPath); err != nil {
		capability.Reason = "未挂载 /var/run/docker.sock"
		return capability
	}
	if _, err := os.Stat(capability.ComposeFile); err != nil {
		capability.Reason = "未找到 docker-compose.yml"
		return capability
	}
	if _, err := os.Stat(e.envPath()); err != nil {
		capability.Reason = "未找到 .env"
		return capability
	}

	cmd := exec.CommandContext(ctx, "docker", "compose", "version")
	if out, err := cmd.CombinedOutput(); err != nil {
		capability.Reason = fmt.Sprintf("docker compose 不可用: %s", strings.TrimSpace(string(out)))
		return capability
	}

	capability.Capable = true
	return capability
}

func (e *systemUpgradeExecutor) backupFile(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return err
	}
	return os.WriteFile(path+".upgrade.bak", data, 0o644)
}

func (e *systemUpgradeExecutor) replaceComposeFile(composeData []byte) error {
	tmpPath := e.composePath() + ".tmp"
	if err := os.WriteFile(tmpPath, composeData, 0o644); err != nil {
		return err
	}
	return os.Rename(tmpPath, e.composePath())
}

func (e *systemUpgradeExecutor) currentBackendImage(ctx context.Context) (string, error) {
	cmd := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", e.backendContainer)
	out, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("inspect backend image failed: %s", strings.TrimSpace(string(out)))
	}
	return strings.TrimSpace(string(out)), nil
}

func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID string) (string, error) {
	helperName := fmt.Sprintf("flvx-upgrade-helper-%d", time.Now().Unix())
	args := e.buildHelperRunArgs(imageID, helperName)
	cmd := exec.CommandContext(ctx, "docker", args...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("start helper failed: %s", strings.TrimSpace(string(out)))
	}
	return strings.TrimSpace(string(out)), nil
}

func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) {
	url := h.buildGithubDownloadURL(version, filename)
	client := &http.Client{Timeout: 60 * time.Second}
	resp, err := client.Get(url)
	if err != nil {
		return nil, fmt.Errorf("download %s failed: %w", filename, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return nil, fmt.Errorf("download %s failed: %d %s", filename, resp.StatusCode, strings.TrimSpace(string(body)))
	}
	return io.ReadAll(resp.Body)
}

func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		response.WriteJSON(w, response.ErrDefault("请求失败"))
		return
	}

	executor := newSystemUpgradeExecutor()
	capability := executor.checkCapability(r.Context())
	latestVersion, _ := resolveLatestReleaseByChannel(releaseChannelStable)
	currentVersion := currentPanelVersion()

	response.WriteJSON(w, response.OK(systemUpgradeVersionData{
		CurrentVersion:   currentVersion,
		Channel:          releaseChannelStable,
		LatestVersion:    latestVersion,
		HasUpdate:        latestVersion != "" && latestVersion != currentVersion,
		Capable:          capability.Capable,
		Reason:           capability.Reason,
		DeployDir:        capability.DeployDir,
		ComposeFile:      capability.ComposeFile,
		BackendContainer: capability.BackendContainer,
	}))
}

func (h *Handler) systemCheckUpdates(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		response.WriteJSON(w, response.ErrDefault("请求失败"))
		return
	}

	var req struct {
		Channel string `json:"channel"`
	}
	if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
		response.WriteJSON(w, response.ErrDefault("请求参数错误"))
		return
	}

	channel := normalizeReleaseChannel(req.Channel)
	releases, err := fetchGitHubReleases(50)
	if err != nil {
		response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
		return
	}

	items := make([]systemUpgradeReleaseItem, 0, len(releases))
	for _, release := range releases {
		if release.Draft {
			continue
		}
		tag := strings.TrimSpace(release.TagName)
		if tag == "" || releaseChannelFromTag(tag) != channel {
			continue
		}
		items = append(items, systemUpgradeReleaseItem{
			Version:     tag,
			Name:        release.Name,
			PublishedAt: release.PublishedAt,
			Prerelease:  channel == releaseChannelDev,
			Channel:     channel,
		})
	}

	executor := newSystemUpgradeExecutor()
	capability := executor.checkCapability(r.Context())
	latestVersion := ""
	if len(items) > 0 {
		latestVersion = items[0].Version
	}
	currentVersion := currentPanelVersion()

	response.WriteJSON(w, response.OK(systemUpgradeCheckData{
		CurrentVersion:   currentVersion,
		Channel:          channel,
		LatestVersion:    latestVersion,
		HasUpdate:        latestVersion != "" && latestVersion != currentVersion,
		Capable:          capability.Capable,
		Reason:           capability.Reason,
		DeployDir:        capability.DeployDir,
		ComposeFile:      capability.ComposeFile,
		BackendContainer: capability.BackendContainer,
		Releases:         items,
	}))
}

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.Err(-2, systemUpgradeConflictError))
		return
	}
	defer h.systemUpgradeMu.Unlock()

	var req struct {
		Version string `json:"version"`
		Channel string `json:"channel"`
	}
	if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
		response.WriteJSON(w, response.ErrDefault("请求参数错误"))
		return
	}

	channel := normalizeReleaseChannel(req.Channel)
	version := strings.TrimSpace(req.Version)
	if version == "" {
		resolved, err := resolveLatestReleaseByChannel(channel)
		if err != nil {
			response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
			return
		}
		version = resolved
	}

	executor := newSystemUpgradeExecutor()
	capability := executor.checkCapability(r.Context())
	if !capability.Capable {
		response.WriteJSON(w, response.Err(-2, capability.Reason))
		return
	}

	currentCompose, err := os.ReadFile(executor.composePath())
	if err != nil {
		response.WriteJSON(w, response.Err(-2, fmt.Sprintf("读取 compose 文件失败: %v", err)))
		return
	}
	assetName := executor.selectComposeAsset(currentCompose)
	composeData, err := h.downloadReleaseAsset(version, assetName)
	if err != nil {
		response.WriteJSON(w, response.Err(-2, err.Error()))
		return
	}
	if err := executor.backupFile(executor.composePath()); err != nil {
		response.WriteJSON(w, response.Err(-2, fmt.Sprintf("备份 compose 文件失败: %v", err)))
		return
	}
	if err := executor.backupFile(executor.envPath()); err != nil {
		response.WriteJSON(w, response.Err(-2, fmt.Sprintf("备份 .env 失败: %v", err)))
		return
	}
	if err := executor.replaceComposeFile(composeData); err != nil {
		response.WriteJSON(w, response.Err(-2, fmt.Sprintf("更新 compose 文件失败: %v", err)))
		return
	}
	if err := executor.updateEnvVersion(executor.envPath(), version); err != nil {
		response.WriteJSON(w, response.Err(-2, fmt.Sprintf("更新 FLUX_VERSION 失败: %v", err)))
		return
	}

	helperCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
	defer cancel()
	imageID, err := executor.currentBackendImage(helperCtx)
	if err != nil {
		response.WriteJSON(w, response.Err(-2, err.Error()))
		return
	}
	helperContainerID, err := executor.startHelper(helperCtx, imageID)
	if err != nil {
		response.WriteJSON(w, response.Err(-2, err.Error()))
		return
	}

	response.WriteJSON(w, response.OK(systemUpgradeRunData{
		Version:           version,
		Message:           systemUpgradeMessage,
		HelperContainerID: helperContainerID,
		Commands: []string{
			"docker run -d --rm --volumes-from flux-panel-backend ...",
			"docker compose pull backend frontend",
			"docker compose up -d backend frontend",
		},
	}))
}

Leave go-backend/internal/http/handler/upgrade.go using the existing release-channel helpers and buildGithubDownloadURL as-is; system_upgrade.go should call those helpers rather than duplicating them.

  • Step 4: Run the focused backend tests again

Run:

go test ./internal/http/handler -run 'Test(SystemVersionRejectsWrongMethod|SystemUpgradeRejectsConcurrentRequests|SelectComposeAsset|UpdateEnvVersion|ValidateBackendContainerName|BuildHelperRunArgs)' -count=1

Expected: PASS.

  • Step 5: Commit only if the user explicitly requested a commit
git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/upgrade.go go-backend/internal/http/handler/system_upgrade.go go-backend/internal/http/handler/system_upgrade_test.go
git commit -m "feat: add panel self-upgrade backend"

Task 3: Add Docker CLI support and deployment mounts

Files:

  • Modify: go-backend/Dockerfile

  • Modify: docker-compose-v4.yml

  • Modify: docker-compose-v6.yml

  • Step 1: Update the Docker Compose templates with runtime env and mounts

Change both docker-compose-v4.yml and docker-compose-v6.yml backend services to include these exact lines:

  backend:
    image: ghcr.io/sagit-chu/flux-panel-backend:${FLUX_VERSION:-latest}
    container_name: flux-panel-backend
    restart: unless-stopped
    environment:
      DB_TYPE: ${DB_TYPE:-sqlite}
      DB_PATH: /app/data/gost.db
      DATABASE_URL: ${DATABASE_URL:-}
      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
    volumes:
      - sqlite_data:/app/data
      - /var/run/docker.sock:/var/run/docker.sock
      - ./:/opt/flvx-panel
  • Step 2: Validate the two Compose templates render cleanly

Run:

JWT_SECRET=test BACKEND_PORT=6365 FRONTEND_PORT=6366 FLUX_VERSION=dev docker compose -f docker-compose-v4.yml config >/tmp/flvx-v4.rendered.yml
JWT_SECRET=test BACKEND_PORT=6365 FRONTEND_PORT=6366 FLUX_VERSION=dev docker compose -f docker-compose-v6.yml config >/tmp/flvx-v6.rendered.yml

Expected: both commands exit 0, and each rendered backend service includes PANEL_DEPLOY_DIR, PANEL_BACKEND_CONTAINER, and /var/run/docker.sock:/var/run/docker.sock.

  • Step 3: Update the backend runtime image to include Docker CLI + compose plugin

Replace go-backend/Dockerfile with this structure:

FROM golang:1.25-bookworm AS builder
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
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
ENTRYPOINT ["/app/paneld"]
  • Step 4: Build and smoke-test the backend image

Run:

docker build -t flvx-backend-system-upgrade-test ./go-backend
docker run --rm flvx-backend-system-upgrade-test docker compose version

Expected: the image build exits 0, and the container prints a compose version string and exits 0.

  • Step 5: Commit only if the user explicitly requested a commit
git add go-backend/Dockerfile docker-compose-v4.yml docker-compose-v6.yml
git commit -m "feat: wire docker runtime for panel upgrades"

Task 4: Add typed frontend API wrappers for system upgrade

Files:

  • Modify: vite-frontend/src/api/types.ts
  • Modify: vite-frontend/src/api/index.ts
  • Modify: vite-frontend/src/pages/config.tsx

Note: This repo has no frontend unit test runner. Use pnpm run build as the failing/passing contract check for the TypeScript surface.

  • Step 1: Make the config page import the not-yet-existing API members

Update the imports at the top of vite-frontend/src/pages/config.tsx to include the missing members before you implement them:

import {
  updateConfigs,
  activateLicense,
  exportBackup,
  importBackup,
  getAnnouncement,
  updateAnnouncement,
  getStorageSummary,
  getSystemUpgradeVersion,
  checkSystemUpgrade,
  runSystemUpgrade,
  type AnnouncementData,
} from "@/api";
import type {
  NodeReleaseApiItem,
  SystemUpgradeCheckApiData,
  SystemUpgradeRunApiData,
  SystemUpgradeVersionApiData,
} from "@/api/types";
  • Step 2: Run the frontend build to verify it fails

Run:

pnpm run build

Expected: FAIL with TypeScript errors such as Module '"@/api"' has no exported member 'getSystemUpgradeVersion' and Module '"@/api/types"' has no exported member 'SystemUpgradeVersionApiData'.

  • Step 3: Add the missing frontend types and API functions

Append these interfaces near the existing StorageSummaryApiData and monitor types in vite-frontend/src/api/types.ts:

export interface SystemUpgradeVersionApiData {
  currentVersion: string;
  channel: "stable" | "dev";
  latestVersion?: string;
  hasUpdate: boolean;
  capable: boolean;
  reason?: string;
  deployDir?: string;
  composeFile?: string;
  backendContainer?: string;
}

export interface SystemUpgradeCheckApiData
  extends SystemUpgradeVersionApiData {
  releases: NodeReleaseApiItem[];
}

export interface SystemUpgradeRunApiData {
  version: string;
  message: string;
  commands: string[];
  helperContainerId?: string;
}

Then update the top import block and exports in vite-frontend/src/api/index.ts:

import type {
  BatchOperationResult,
  ForwardDiagnosisApiData,
  ForwardApiItem,
  GroupPermissionApiItem,
  NodeReleaseApiItem,
  NodeApiItem,
  SpeedLimitApiItem,
  StorageSummaryApiData,
  SystemUpgradeCheckApiData,
  SystemUpgradeRunApiData,
  SystemUpgradeVersionApiData,
  TunnelApiItem,
  TunnelBatchDeletePreviewApiData,
  TunnelBatchDeleteWithForwardsApiData,
  TunnelDeletePreviewApiData,
  TunnelDeleteWithForwardsApiData,
  TunnelDiagnosisApiData,
  TunnelGroupApiItem,
  TunnelMetricApiItem,
  TunnelQualityApiItem,
  UpdatePasswordPayload,
  UserApiItem,
  UserGroupApiItem,
  UserListQuery,
  UserMutationPayload,
  UserPackageInfoApiData,
  UserQuotaResetPayload,
  UserTunnelApiItem,
  UserTunnelAssignPayload,
  UserTunnelListQuery,
  UserTunnelPermissionApiItem,
  UserTunnelRemovePayload,
} from "./types";

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 },
  );
  • Step 4: Run the frontend build again

Run:

pnpm run build

Expected: PASS, because the new imports now exist even though the config page does not render the upgrade UI yet.

  • Step 5: Commit only if the user explicitly requested a commit
git add vite-frontend/src/api/index.ts vite-frontend/src/api/types.ts vite-frontend/src/pages/config.tsx
git commit -m "feat: add frontend api surface for panel upgrades"

Task 5: Add the settings-page upgrade card and confirmation modal

Files:

  • Modify: vite-frontend/src/pages/config.tsx

Note: This repo has no frontend unit test runner. Use pnpm run build as the failing/passing contract check for the UI state and JSX wiring.

  • Step 1: Add the upgrade card and modal markup before defining the state/handlers

Insert this JSX block just after the database storage section in vite-frontend/src/pages/config.tsx, before the save button row:

          <Divider className="my-2" />

          <div className="space-y-3">
            <div className="flex flex-col gap-1 md:flex-row md:items-start md:justify-between">
              <div>
                <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">
                  升级 backend 与 frontend 容器。操作会短暂中断面板访问,仅管理员可用。
                </p>
              </div>
              <div className="flex flex-wrap gap-2">
                <Button
                  isLoading={systemUpgradeChecking}
                  size="sm"
                  variant="flat"
                  onPress={handleCheckSystemUpgrade}
                >
                  检查更新
                </Button>
                <Button
                  color="primary"
                  isDisabled={!systemUpgradeInfo?.capable || systemUpgradeLoading}
                  size="sm"
                  onPress={handleOpenSystemUpgradeModal}
                >
                  立即升级
                </Button>
              </div>
            </div>

            <div className="rounded-lg border border-divider bg-default-50/60 px-4 py-3 dark:bg-default-100/10">
              {systemUpgradeLoading ? (
                <p className="text-sm text-default-500">加载升级状态中...</p>
              ) : (
                <div className="space-y-2 text-sm text-default-700 dark:text-default-300">
                  <p>当前版本:{systemUpgradeInfo?.currentVersion || "未知"}</p>
                  <p>最新版本:{systemUpgradeInfo?.latestVersion || "未检查"}</p>
                  <p>当前通道:{updateChannel === "stable" ? "稳定版" : "开发版"}</p>
                  <p>
                    升级能力:
                    {systemUpgradeInfo?.capable ? "可用" : systemUpgradeInfo?.reason || "不可用"}
                  </p>
                  {systemUpgradeInfo?.deployDir ? (
                    <p>部署目录:{systemUpgradeInfo.deployDir}</p>
                  ) : null}
                </div>
              )}
            </div>
          </div>

          <Modal
            isOpen={systemUpgradeModalOpen}
            onOpenChange={setSystemUpgradeModalOpen}
          >
            <ModalContent>
              <ModalHeader>确认升级面板</ModalHeader>
              <ModalBody className="space-y-4">
                <div className="rounded-lg border border-warning-300 bg-warning-50 px-4 py-3 text-sm text-warning-800 dark:border-warning-700 dark:bg-warning-900/20 dark:text-warning-200">
                  该操作会通过 Docker socket 控制宿主机 Docker,并短暂重启当前面板服务。
                </div>
                <Select
                  label="目标版本"
                  placeholder="留空时自动选择最新版本"
                  selectedKeys={systemUpgradeSelectedVersion ? [systemUpgradeSelectedVersion] : []}
                  variant="bordered"
                  onSelectionChange={(keys) => {
                    const selected = (Array.from(keys)[0] as string) || "";
                    setSystemUpgradeSelectedVersion(selected);
                  }}
                >
                  {systemUpgradeReleases.map((release) => (
                    <SelectItem
                      key={release.version}
                      description={release.publishedAt}
                    >
                      {release.version}
                    </SelectItem>
                  ))}
                </Select>
              </ModalBody>
              <ModalFooter>
                <Button variant="light" onPress={() => setSystemUpgradeModalOpen(false)}>
                  取消
                </Button>
                <Button
                  color="primary"
                  isLoading={systemUpgradeExecuting}
                  onPress={handleConfirmSystemUpgrade}
                >
                  确认升级
                </Button>
              </ModalFooter>
            </ModalContent>
          </Modal>
  • Step 2: Run the frontend build to verify it fails

Run:

pnpm run build

Expected: FAIL with TypeScript errors like Cannot find name 'systemUpgradeInfo', Cannot find name 'handleCheckSystemUpgrade', and Cannot find name 'systemUpgradeReleases'.

  • Step 3: Add the missing state, loaders, handlers, and modal control

Add these hooks near the existing config page state in vite-frontend/src/pages/config.tsx:

  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<
    NodeReleaseApiItem[]
  >([]);
  const [systemUpgradeSelectedVersion, setSystemUpgradeSelectedVersion] =
    useState("");

Add these helpers near loadStorageSummary and handleUpdateChannelChange:

  const loadSystemUpgradeInfo = async () => {
    setSystemUpgradeLoading(true);
    try {
      const response = await getSystemUpgradeVersion();

      if (response.code === 0 && response.data) {
        setSystemUpgradeInfo(response.data);
      } else {
        setSystemUpgradeInfo(null);
      }
    } catch {
      setSystemUpgradeInfo(null);
    } finally {
      setSystemUpgradeLoading(false);
    }
  };

  const handleCheckSystemUpgrade = async () => {
    setSystemUpgradeChecking(true);
    try {
      const response = await checkSystemUpgrade(updateChannel);

      if (response.code === 0 && response.data) {
        const data = response.data as SystemUpgradeCheckApiData;
        setSystemUpgradeInfo(data);
        setSystemUpgradeReleases(data.releases || []);
        toast.success(
          data.latestVersion
            ? `已检查到最新版本 ${data.latestVersion}`
            : "未获取到可用版本",
        );
      } else {
        toast.error(response.msg || "检查更新失败");
      }
    } catch {
      toast.error("检查更新失败,请重试");
    } finally {
      setSystemUpgradeChecking(false);
    }
  };

  const handleOpenSystemUpgradeModal = async () => {
    setSystemUpgradeModalOpen(true);
    if (systemUpgradeReleases.length === 0) {
      await handleCheckSystemUpgrade();
    }
  };

  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;
        toast.success(data.message || "升级已触发,请稍后刷新页面");
        setSystemUpgradeModalOpen(false);
      } else {
        toast.error(response.msg || "面板升级失败");
      }
    } catch {
      toast.error("面板升级失败,请重试");
    } finally {
      setSystemUpgradeExecuting(false);
    }
  };

Update the initial useEffect so it loads the new status alongside configs and storage:

  useEffect(() => {
    const timer = setTimeout(() => {
      loadConfigs(initialConfigs);
      loadAnnouncement();
      loadStorageSummary();
      loadSystemUpgradeInfo();
    }, 100);

    return () => clearTimeout(timer);
  }, []);

Finally, extend handleUpdateChannelChange so changing the update channel clears stale release picks and refreshes the displayed status:

  const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
    setUpdateChannel(channel);
    setUpdateReleaseChannel(channel);
    setSystemUpgradeSelectedVersion("");
    setSystemUpgradeReleases([]);
    void loadSystemUpgradeInfo();
    toast.success(
      `更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
    );
  };
  • Step 4: Run the frontend build again

Run:

pnpm run build

Expected: PASS.

  • Step 5: Commit only if the user explicitly requested a commit
git add vite-frontend/src/pages/config.tsx
git commit -m "feat: add panel self-upgrade settings ui"

Task 6: Run the full verification suite and admin QA smoke checks

Files:

  • No new files.

  • Step 1: Re-run the targeted backend unit tests for the new helper and handlers

Run:

go test ./internal/http/handler -run 'Test(SelectComposeAsset|UpdateEnvVersion|ValidateBackendContainerName|BuildHelperRunArgs|SystemVersionRejectsWrongMethod|SystemUpgradeRejectsConcurrentRequests)' -count=1

Expected: PASS.

  • Step 2: Run the full backend test suite

Run:

go test ./...

Expected: PASS.

  • Step 3: Run the frontend production build

Run:

pnpm run build

Expected: PASS.

  • Step 4: Re-run the Compose template checks

Run:

JWT_SECRET=test BACKEND_PORT=6365 FRONTEND_PORT=6366 FLUX_VERSION=dev docker compose -f docker-compose-v4.yml config >/tmp/flvx-v4.rendered.yml
JWT_SECRET=test BACKEND_PORT=6365 FRONTEND_PORT=6366 FLUX_VERSION=dev docker compose -f docker-compose-v6.yml config >/tmp/flvx-v6.rendered.yml

Expected: PASS for both templates.

  • Step 5: Re-run the backend image smoke test

Run:

docker build -t flvx-backend-system-upgrade-test ./go-backend
docker run --rm flvx-backend-system-upgrade-test docker compose version

Expected: PASS, with the second command printing a compose version string.

  • Step 6: Manually smoke-test the admin flow in the browser

Manual checklist:

1. Log in as an admin user.
2. Open /config.
3. Confirm the “面板升级” card shows current version and capability state.
4. In an environment without /var/run/docker.sock, confirm the card shows the capability reason and disables the upgrade button.
5. In a Docker-enabled environment, click “检查更新” and confirm the latest version + release list populate.
6. Open the confirmation modal and confirm the warning about Docker socket / short restart is visible.

Expected: all six checks succeed.

  • Step 7: Commit only if the user explicitly requested a commit
git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/system_upgrade.go go-backend/internal/http/handler/system_upgrade_test.go go-backend/Dockerfile docker-compose-v4.yml docker-compose-v6.yml vite-frontend/src/api/index.ts vite-frontend/src/api/types.ts vite-frontend/src/pages/config.tsx
git commit -m "feat: add panel self-upgrade workflow"