mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user