fix(panel): make self-upgrades durable and observable (#572)

Persist upgrade jobs and progress across backend restarts; preserve Compose identity and database mounts, isolate backups, and fail safely on uncertain recreation. Add frontend recovery handling and lifecycle/permission regressions.
This commit is contained in:
sagit
2026-11-09 20:24:03 +08:00
committed by GitHub
parent a784350eaf
commit 7e81e1c65c
17 changed files with 1623 additions and 477 deletions
+8
View File
@@ -44,6 +44,7 @@ import type {
StorageSummaryApiData,
SystemUpgradeCheckApiData,
SystemUpgradeRunApiData,
SystemUpgradeStatusApiData,
SystemUpgradeVersionApiData,
} from "./types";
@@ -317,6 +318,13 @@ export const checkSystemUpgrade = (channel: ReleaseChannel = "stable") =>
channel,
});
export const getSystemUpgradeStatus = (jobId?: string) =>
Network.post<SystemUpgradeStatusApiData>(
"/system/upgrade/status",
jobId ? { jobId } : {},
{ timeout: 10000 },
);
export const runSystemUpgrade = (
version?: string,
channel: ReleaseChannel = "stable",
+15 -2
View File
@@ -46,6 +46,7 @@ interface ApiResponse<T = unknown> {
code: number;
msg: string;
data: T;
transportError?: boolean;
}
interface RequestOptions {
@@ -82,7 +83,12 @@ const Network = {
return new Promise(function (resolve) {
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
if (baseURL === "") {
resolve({ code: -1, msg: " - 请先设置面板地址", data: null as T });
resolve({
code: -1,
msg: " - 请先设置面板地址",
data: null as T,
transportError: true,
});
return;
}
@@ -121,6 +127,7 @@ const Network = {
resolve({
code: -1,
transportError: true,
msg: errorMessage,
data: null as T,
});
@@ -136,7 +143,12 @@ const Network = {
return new Promise(function (resolve) {
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
if (baseURL === "") {
resolve({ code: -1, msg: " - 请先设置面板地址", data: null as T });
resolve({
code: -1,
msg: " - 请先设置面板地址",
data: null as T,
transportError: true,
});
return;
}
@@ -175,6 +187,7 @@ const Network = {
resolve({
code: -1,
transportError: true,
msg: errorMessage,
data: null as T,
});
+29
View File
@@ -550,7 +550,36 @@ export interface SystemUpgradeCheckApiData extends SystemUpgradeVersionApiData {
releases: SystemUpgradeReleaseApiItem[];
}
export type SystemUpgradeStage =
| "preparing"
| "pulling"
| "applying"
| "restarting"
| "verifying"
| "succeeded"
| "failed"
| "recovery_required";
export interface SystemUpgradeJob {
id: string;
targetVersion: string;
stage: SystemUpgradeStage;
active: boolean;
requiresRecovery: boolean;
message: string;
startedAt: number;
updatedAt: number;
helperContainer?: string;
backupDir?: string;
}
export interface SystemUpgradeStatusApiData {
currentVersion: string;
job: SystemUpgradeJob | null;
}
export interface SystemUpgradeRunApiData {
jobId: string;
version: string;
channel: "stable" | "dev";
composeAsset: string;
@@ -0,0 +1,37 @@
import { useEffect, useRef, useState } from "react";
import { getSystemUpgradeStatus, runSystemUpgrade } from "@/api";
import {
createUpgradeMonitor,
initialUpgradeState,
upgradeIsBlocked,
} from "@/utils/system-upgrade-monitor";
export function useSystemUpgrade(enabled: boolean) {
const [state, setState] = useState(initialUpgradeState);
const monitor = useRef<ReturnType<typeof createUpgradeMonitor> | null>(null);
useEffect(() => {
if (!enabled) return;
const instance = createUpgradeMonitor(
{ status: getSystemUpgradeStatus, run: runSystemUpgrade },
setState,
);
monitor.current = instance;
instance.refresh();
return () => {
instance.dispose();
monitor.current = null;
};
}, [enabled]);
return {
...state,
blocked: upgradeIsBlocked(state),
refresh: () => monitor.current?.refresh(),
submit: (version?: string, channel?: "stable" | "dev") =>
monitor.current?.submit(version, channel) ?? Promise.resolve(false),
};
}
+125 -28
View File
@@ -1,6 +1,5 @@
import type {
SystemUpgradeCheckApiData,
SystemUpgradeRunApiData,
SystemUpgradeReleaseApiItem,
SystemUpgradeVersionApiData,
} from "@/api/types";
@@ -10,6 +9,8 @@ import { useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import toast from "react-hot-toast";
import { useSystemUpgrade } from "@/hooks/use-system-upgrade";
import { upgradeMessage } from "@/utils/system-upgrade-monitor";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -36,7 +37,6 @@ import {
getStorageSummary,
getSystemUpgradeVersion,
checkSystemUpgrade,
runSystemUpgrade,
type AnnouncementData,
} from "@/api";
import { BackIcon, SettingsIcon } from "@/components/icons";
@@ -336,7 +336,9 @@ export default function ConfigPage() {
const [systemUpgradeInfo, setSystemUpgradeInfo] =
useState<SystemUpgradeVersionApiData | null>(null);
const [systemUpgradeChecking, setSystemUpgradeChecking] = useState(false);
const [systemUpgradeExecuting, setSystemUpgradeExecuting] = useState(false);
const upgrade = useSystemUpgrade(isAdmin());
const systemUpgradeExecuting =
upgrade.submitting || Boolean(upgrade.job?.active);
const [systemUpgradeLoading, setSystemUpgradeLoading] = useState(true);
const [systemUpgradeModalOpen, setSystemUpgradeModalOpen] = useState(false);
const [systemUpgradeReleases, setSystemUpgradeReleases] = useState<
@@ -363,17 +365,38 @@ export default function ConfigPage() {
const canTriggerSystemUpgrade = Boolean(
!systemUpgradeLoading &&
!systemUpgradeChecking &&
!systemUpgradeExecuting &&
systemUpgradeInfo?.capability.capable !== false,
!upgrade.blocked &&
systemUpgradeInfo?.capability.capable === true,
);
const canOpenSystemUpgradeModal = Boolean(
systemUpgradeInfo?.capability.capable &&
systemUpgradeHasConfirmedUpdate &&
!systemUpgradeLoading &&
!systemUpgradeChecking &&
!systemUpgradeExecuting,
!upgrade.blocked,
);
useEffect(() => {
if (upgrade.job?.stage !== "succeeded" || upgrade.uncertain) return;
setSystemUpgradeReleases([]);
setSystemUpgradeCheckedChannel(null);
setSystemUpgradeSelectedVersion("");
setSystemUpgradeInfo((previous) =>
previous
? {
...previous,
currentVersion: upgrade.currentVersion,
hasUpdate: false,
}
: previous,
);
}, [
upgrade.job?.id,
upgrade.job?.stage,
upgrade.currentVersion,
upgrade.uncertain,
]);
const canGoBack =
typeof window !== "undefined" &&
typeof window.history.state?.idx === "number" &&
@@ -572,6 +595,7 @@ export default function ConfigPage() {
};
const handleOpenSystemUpgradeModal = async () => {
if (upgrade.blocked) return;
if (!canOpenSystemUpgradeModal) {
const checked = await handleCheckSystemUpgrade();
@@ -583,26 +607,15 @@ export default function ConfigPage() {
};
const handleConfirmSystemUpgrade = async () => {
setSystemUpgradeExecuting(true);
try {
const response = await runSystemUpgrade(
systemUpgradeSelectedVersion || undefined,
updateChannel,
);
if (!canTriggerSystemUpgrade) return;
const submitted = await upgrade.submit(
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);
if (submitted) {
setSystemUpgradeModalOpen(false);
setSystemUpgradeSelectedVersion("");
}
};
@@ -1647,6 +1660,87 @@ export default function ConfigPage() {
</p>
</div>
<div
aria-live="polite"
className="space-y-2 rounded-lg border border-divider bg-background p-4 text-sm"
>
<p className="font-medium">
{upgrade.submitting
? "正在提交升级任务..."
: upgrade.job
? {
preparing: "正在准备升级",
pulling: "正在拉取镜像",
applying: "正在应用升级",
restarting: "正在重启服务",
verifying: "正在验证运行版本",
succeeded: "升级完成,运行版本已验证",
failed: "升级失败",
recovery_required: "升级中断,需要人工恢复",
}[upgrade.job.stage] || "升级状态待确认"
: upgrade.checking
? "正在恢复升级状态..."
: upgrade.uncertain
? "升级状态待确认"
: "暂无升级任务"}
</p>
{upgrade.job && (
<>
<p className="break-words text-default-600">
目标版本:{upgrade.job.targetVersion}
</p>
{upgrade.job.message && (
<p className="break-words text-default-600">
{upgradeMessage(upgrade.job.message)}
</p>
)}
{upgrade.job.active && (
<p className="text-default-500">
升级期间请勿重复操作。后端重启可能短暂断开连接,本页面会自动重新连接并验证结果。
</p>
)}
{upgrade.job.stage === "succeeded" && !upgrade.uncertain && (
<p className="text-success">
后端已验证运行版本 {upgrade.currentVersion}
。请刷新页面以加载新版前端。
</p>
)}
{(upgrade.job.requiresRecovery ||
upgrade.job.stage === "recovery_required") && (
<p className="text-danger">
已阻止再次升级。请联系服务器管理员检查升级辅助容器日志和部署目录中的备份,确认服务与数据库状态后按部署文档恢复;不要删除备份或反复触发升级。
</p>
)}
{upgrade.job.stage === "failed" && (
<p className="text-danger">
请先排查失败原因,再检查更新并重试。
</p>
)}
</>
)}
{upgrade.uncertain && !upgrade.checking && (
<p className="text-warning">
暂时无法确认升级结果,可能正在重启或连接已中断。这不代表升级失败,请勿重复提交。
</p>
)}
{upgrade.paused && (
<p className="text-warning">
自动查询已暂停,任务可能仍在运行。请检查服务连接后点击“重新查询状态”;不会重新发起升级。
</p>
)}
{upgrade.notice && (
<p className="text-danger">{upgrade.notice}</p>
)}
<Button
isDisabled={upgrade.checking || upgrade.submitting}
size="sm"
variant="flat"
onPress={upgrade.refresh}
>
重新查询状态
</Button>
</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" />
@@ -1658,7 +1752,9 @@ export default function ConfigPage() {
<div>
<p className="text-xs text-default-500">当前版本</p>
<p className="mt-1 font-medium">
{systemUpgradeInfo?.currentVersion || "未获取到版本信息"}
{upgrade.currentVersion ||
systemUpgradeInfo?.currentVersion ||
"未获取到版本信息"}
</p>
</div>
<div>
@@ -1776,6 +1872,7 @@ export default function ConfigPage() {
<div className="flex flex-col gap-3 pt-1 sm:flex-row sm:justify-end">
<Button
isDisabled={upgrade.blocked}
isLoading={systemUpgradeChecking}
variant="flat"
onPress={handleCheckSystemUpgrade}
@@ -2078,8 +2175,8 @@ export default function ConfigPage() {
</Button>
<Button
color="primary"
isDisabled={systemUpgradeExecuting}
isLoading={systemUpgradeExecuting}
isDisabled={!canTriggerSystemUpgrade}
isLoading={upgrade.submitting}
onPress={handleConfirmSystemUpgrade}
>
确认升级
@@ -0,0 +1,206 @@
import type {
SystemUpgradeJob,
SystemUpgradeRunApiData,
SystemUpgradeStatusApiData,
} from "@/api/types";
type Response<T> = {
code: number;
data: T;
msg?: string;
transportError?: boolean;
};
export interface UpgradeMonitorState {
job: SystemUpgradeJob | null;
currentVersion: string;
checking: boolean;
submitting: boolean;
uncertain: boolean;
paused: boolean;
notice: string;
}
export const initialUpgradeState: UpgradeMonitorState = {
job: null,
currentVersion: "",
checking: true,
submitting: false,
uncertain: true,
paused: false,
notice: "",
};
export const upgradeIsBlocked = (state: UpgradeMonitorState) =>
state.checking ||
state.submitting ||
state.uncertain ||
Boolean(
state.job?.active ||
state.job?.requiresRecovery ||
state.job?.stage === "recovery_required",
);
// The server supplies sanitized public summaries, never helper logs. Render as
// text only; remove control characters and cap length as defense in depth.
export const upgradeMessage = (message: string) =>
message.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 500);
// Kept independent of React so lifecycle/disconnect races can be replayed
// without adding frontend test infrastructure.
export function createUpgradeMonitor(
api: {
status: (id?: string) => Promise<Response<SystemUpgradeStatusApiData>>;
run: (
version?: string,
channel?: "stable" | "dev",
) => Promise<Response<SystemUpgradeRunApiData>>;
},
publish: (state: UpgradeMonitorState) => void,
clock = {
now: () => Date.now(),
schedule: (fn: () => void, ms: number) => setTimeout(fn, ms),
cancel: (timer: ReturnType<typeof setTimeout>) => clearTimeout(timer),
},
) {
let state = { ...initialUpgradeState };
let timer: ReturnType<typeof setTimeout> | undefined;
let generation = 0;
let disposed = false;
let busy = false;
let deadline = 0;
let failures = 0;
let expectedJob: string | undefined;
let awaitingSubmission = false;
let previousJob: string | undefined;
const emit = (patch: Partial<UpgradeMonitorState>) => {
state = { ...state, ...patch };
if (!disposed) publish(state);
};
const cancelTimer = () => {
if (timer !== undefined) clock.cancel(timer);
timer = undefined;
};
const poll = async () => {
if (disposed || busy) return;
busy = true;
const request = generation;
try {
const response = await api.status(expectedJob);
if (disposed || request !== generation) return;
if (response.code !== 0 || !response.data) throw new Error("unavailable");
const { job, currentVersion } = response.data;
// An accepted or interrupted POST must not be mistaken for an idle
// server, nor may an older completed job certify the new request.
if (
(expectedJob && job?.id !== expectedJob) ||
(awaitingSubmission && (!job || job.id === previousJob))
) {
throw new Error("awaiting job");
}
failures = 0;
awaitingSubmission = false;
expectedJob = job?.id;
emit({
job,
currentVersion,
checking: false,
uncertain: false,
paused: false,
});
} catch {
if (disposed || request !== generation) return;
failures += 1;
// Disconnection is not proof of failure. Keep the last known job and
// continue blocking submissions until authoritative status is available.
emit({ checking: false, uncertain: true });
} finally {
busy = false;
if (
!disposed &&
request === generation &&
(state.uncertain || state.job?.active)
) {
if (clock.now() >= deadline) {
emit({ paused: true });
} else {
timer = clock.schedule(
() => {
void poll();
},
Math.min(15000, 2000 * 2 ** Math.min(failures, 3)),
);
}
}
}
};
return {
refresh() {
if (disposed || busy || state.submitting) return;
cancelTimer();
if (
!state.uncertain &&
!state.job?.active &&
!state.job?.requiresRecovery
) {
expectedJob = undefined;
}
deadline = clock.now() + 15 * 60 * 1000;
failures = 0;
emit({ checking: true, paused: false });
void poll();
},
async submit(version?: string, channel?: "stable" | "dev") {
if (disposed || busy || upgradeIsBlocked(state)) return false;
cancelTimer();
const request = ++generation;
previousJob = state.job?.id;
expectedJob = undefined;
awaitingSubmission = true;
emit({
job: null,
submitting: true,
uncertain: true,
paused: false,
notice: "",
});
try {
const response = await api.run(version, channel);
if (disposed || request !== generation) return false;
if (response.code === 0 && response.data?.jobId) {
expectedJob = response.data.jobId;
awaitingSubmission = false;
} else if (response.code !== 0 && !response.transportError) {
// Definite server rejection: rediscover the authoritative job,
// including concurrent submissions from other admin sessions.
awaitingSubmission = false;
emit({
notice: upgradeMessage(
response.msg || "升级请求被拒绝,请检查当前任务状态。",
),
});
}
} catch {
// The POST may already have started the helper. Reconcile, don't retry.
} finally {
if (!disposed && request === generation) {
emit({ submitting: false });
deadline = clock.now() + 15 * 60 * 1000;
void poll();
}
}
return !disposed && request === generation;
},
dispose() {
disposed = true;
generation += 1;
cancelTimer();
},
};
}