fix: panel self-upgrade helper not recreating containers (#495)

## Summary

- **Helper container `docker compose up` 不会强制重建容器**:原脚本缺少
`--force-recreate`,Docker Compose
在检测不到配置变化时不会替换运行中的容器,导致拉取了新镜像但旧容器继续运行。新增 `--force-recreate
--remove-orphans` 确保容器被替换。
- **无错误日志**:helper 容器执行失败时没有任何可见反馈。新增 `upgrade.log` 写入部署目录,每一步操作和错误都有记录。
- **"立即升级"按钮永久禁用**:按钮 `isDisabled` 绑定了
`!canOpenSystemUpgradeModal`,该条件要求已完成检查更新且有可用更新,但页面本身有点击时自动检查的逻辑,导致按钮永远无法点击。改为
`!canTriggerSystemUpgrade` 允许自动检查流程触发。

## Verification

| Check | Result |
|-------|--------|
| `go test ./...` | ✅ 498 passed |
| `pnpm run build` | ✅ passed |
| `pnpm run lint` | ✅ passed |
This commit is contained in:
sagit
2026-05-07 19:53:53 +08:00
committed by GitHub
5 changed files with 76 additions and 28 deletions
@@ -180,13 +180,41 @@ func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string {
}
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")
return `set -eu
LOGFILE="$PANEL_DEPLOY_DIR/upgrade.log"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOGFILE"; }
cd "$PANEL_DEPLOY_DIR"
echo "" > "$LOGFILE"
log "开始面板升级"
log "工作目录: $(pwd)"
if [ ! -f docker-compose.yml ]; then
log "错误: docker-compose.yml 不存在"
exit 1
fi
if [ ! -f .env ]; then
log "错误: .env 不存在"
exit 1
fi
log "拉取新镜像..."
if ! docker compose pull backend frontend 2>&1 | tee -a "$LOGFILE"; then
log "错误: 拉取镜像失败"
exit 1
fi
log "等待旧容器释放资源..."
sleep 3
log "重启服务(force-recreate)..."
if ! docker compose up -d --force-recreate --remove-orphans backend frontend 2>&1 | tee -a "$LOGFILE"; then
log "错误: 重启服务失败"
exit 1
fi
log "升级完成"
`
}
func (e *systemUpgradeExecutor) buildHelperRunArgs(imageID, helperName string) ([]string, error) {
+1 -2
View File
@@ -508,8 +508,7 @@ export interface SystemUpgradeVersionApiData {
capability: SystemUpgradeCapabilityApiData;
}
export interface SystemUpgradeCheckApiData
extends SystemUpgradeVersionApiData {
export interface SystemUpgradeCheckApiData extends SystemUpgradeVersionApiData {
releases: SystemUpgradeReleaseApiItem[];
}
+30 -16
View File
@@ -1,3 +1,10 @@
import type {
SystemUpgradeCheckApiData,
SystemUpgradeRunApiData,
SystemUpgradeReleaseApiItem,
SystemUpgradeVersionApiData,
} from "@/api/types";
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
@@ -32,12 +39,6 @@ import {
runSystemUpgrade,
type AnnouncementData,
} from "@/api";
import type {
SystemUpgradeCheckApiData,
SystemUpgradeRunApiData,
SystemUpgradeReleaseApiItem,
SystemUpgradeVersionApiData,
} from "@/api/types";
import { BackIcon, SettingsIcon } from "@/components/icons";
import { ThemeSettings } from "@/components/theme-settings";
import { isAdmin } from "@/utils/auth";
@@ -328,6 +329,12 @@ export default function ConfigPage() {
systemUpgradeReleasesMatchChannel &&
systemUpgradeReleases.length > 0,
);
const canTriggerSystemUpgrade = Boolean(
!systemUpgradeLoading &&
!systemUpgradeChecking &&
!systemUpgradeExecuting &&
systemUpgradeInfo?.capability.capable !== false,
);
const canOpenSystemUpgradeModal = Boolean(
systemUpgradeInfo?.capability.capable &&
systemUpgradeHasConfirmedUpdate &&
@@ -421,7 +428,9 @@ export default function ConfigPage() {
hasUpdate:
response.data.channel === channel ? response.data.hasUpdate : false,
latestVersion:
response.data.channel === channel ? response.data.latestVersion : "",
response.data.channel === channel
? response.data.latestVersion
: "",
});
} else {
setSystemUpgradeInfo(null);
@@ -512,7 +521,9 @@ export default function ConfigPage() {
: "未获取到可用版本",
);
return Boolean(data.capability.capable && data.hasUpdate && data.releases?.length);
return Boolean(
data.capability.capable && data.hasUpdate && data.releases?.length,
);
} else {
setSystemUpgradeReleases([]);
setSystemUpgradeCheckedChannel(null);
@@ -1568,7 +1579,8 @@ export default function ConfigPage() {
<div>
<p className="text-xs text-default-500">部署目录</p>
<p className="mt-1 break-all font-medium">
{systemUpgradeInfo?.capability.deployDir || "未获取到部署目录"}
{systemUpgradeInfo?.capability.deployDir ||
"未获取到部署目录"}
</p>
</div>
<div>
@@ -1625,7 +1637,9 @@ export default function ConfigPage() {
size="md"
variant="bordered"
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as string | undefined;
const selected = Array.from(keys)[0] as
| string
| undefined;
setSystemUpgradeSelectedVersion(selected || "");
}}
@@ -1648,17 +1662,15 @@ export default function ConfigPage() {
<div className="flex flex-col gap-3 pt-1 sm:flex-row sm:justify-end">
<Button
variant="flat"
isLoading={systemUpgradeChecking}
variant="flat"
onPress={handleCheckSystemUpgrade}
>
检查更新
</Button>
<Button
color="primary"
isDisabled={
!canOpenSystemUpgradeModal
}
isDisabled={!canTriggerSystemUpgrade}
isLoading={systemUpgradeExecuting}
onPress={handleOpenSystemUpgradeModal}
>
@@ -1925,10 +1937,12 @@ export default function ConfigPage() {
<ModalBody>
<div className="space-y-3 text-sm text-default-700 dark:text-default-300">
<p>
升级过程需要访问 Docker Socket,并会在短时间内中断当前面板服务。
升级过程需要访问 Docker
Socket,并会在短时间内中断当前面板服务。
</p>
<p>
请确认已经允许面板管理容器与宿主机 Docker 交互,并且可以接受升级期间的临时不可用。
请确认已经允许面板管理容器与宿主机 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>
+9 -2
View File
@@ -204,6 +204,7 @@ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
const toSafeString = (value: unknown): string => {
if (typeof value === "string") return value;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return "";
};
@@ -265,11 +266,13 @@ const normalizeBestExitState = (value: unknown): BestExitState | undefined => {
const bestExitOwnerRoleText = (role?: string) => {
if (role === "chain") return "中转";
return "入口";
};
const bestExitDetailTitle = (state?: BestExitState) => {
if (!state?.items?.length) return undefined;
return state.items
.map(
(item) =>
@@ -3108,7 +3111,9 @@ export default function TunnelPage() {
}}
>
<SelectItem key="1">单向计算(仅上传)</SelectItem>
<SelectItem key="2">双向计算(上传+下载)</SelectItem>
<SelectItem key="2">
双向计算(上传+下载)
</SelectItem>
</Select>
<Input
@@ -3171,7 +3176,9 @@ export default function TunnelPage() {
)}
<div>
<div className="text-sm font-medium">质量检测目标</div>
<div className="text-sm font-medium">
质量检测目标
</div>
<p className="text-xs text-default-500 mt-0.5">
用于实时隧道质量检测、诊断目标和 best
最优出口评分,留空使用 www.bing.com:443
@@ -401,6 +401,7 @@ export function Select<T>({
</div>
) : (
<select
aria-label={label ? undefined : ariaLabel}
className={cn(
"w-full rounded-md border border-input bg-background px-3 py-2 text-foreground shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:[color-scheme:dark]",
sizeClass(size),
@@ -409,7 +410,6 @@ export function Select<T>({
)}
disabled={isDisabled}
id={generatedId}
aria-label={label ? undefined : ariaLabel}
required={isRequired}
value={singleValue}
onChange={handleChange}