feat(deploy): 在线更新 - 新增后台检查、强制备份迁移与失败自动回退

This commit is contained in:
ddCat
2026-08-30 22:15:17 +08:00
parent 8227921c68
commit 694cb974fb
60 changed files with 4124 additions and 93 deletions

View File

@@ -22,6 +22,7 @@ import {
Paintbrush,
PlugZap,
RadioTower,
RefreshCw,
Settings2,
ShieldAlert,
ShieldCheck,
@@ -87,6 +88,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
{ path: "/admin/settings/ark-private-assets", label: "方舟素材库", description: "Seedance 可信参考素材", icon: <CloudUpload className="size-4" /> },
{ path: "/admin/settings/response-interception", label: "模型响应拦截", description: "先启用策略,再配置替换规则", icon: <ShieldAlert className="size-4" /> },
{ path: "/admin/settings/third-party", label: "第三方参数配置", description: "先配置凭据,再开放用户入口", icon: <KeyRound className="size-4" /> },
{ path: "/admin/settings/system-update", label: "系统更新", description: "检查版本、备份与安全更新", icon: <RefreshCw className="size-4" /> },
],
},
];

View File

@@ -0,0 +1,280 @@
import { App, Button, Input, Skeleton } from "antd";
import { AlertTriangle, Archive, BadgeCheck, Circle, ExternalLink, History, RefreshCw, RotateCcw, ServerCog, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { checkSystemUpdate, getSystemUpdateStatus, rollbackSystemUpdate, startSystemUpdate, type SystemUpdateStatus, type UpdatePhase } from "@/services/api/system-update";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
const activePhases = new Set<UpdatePhase>(["checking", "preflight", "backing_up", "pulling", "draining", "migrating", "switching", "verifying", "rolling_back"]);
const phaseLabels: Record<UpdatePhase, string> = {
idle: "等待检查",
checking: "正在检查",
ready: "可更新",
no_update: "已是最新",
preflight: "前置检查",
backing_up: "正在备份",
pulling: "拉取镜像",
draining: "停止旧服务",
migrating: "迁移数据库",
switching: "切换版本",
verifying: "健康验证",
succeeded: "更新成功",
rolling_back: "正在回退",
rolled_back: "已回退",
failed: "更新失败",
manual_intervention: "需人工介入",
};
export default function SystemUpdatePage() {
const { message, modal } = App.useApp();
const [status, setStatus] = useState<SystemUpdateStatus | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [starting, setStarting] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
const [loadError, setLoadError] = useState("");
const mountedRef = useRef(true);
const load = useCallback(async (initial = false) => {
if (initial) setLoading(true);
try {
const next = await getSystemUpdateStatus();
if (!mountedRef.current) return;
setStatus(next);
setLoadError("");
setReconnecting(false);
} catch (error) {
if (!mountedRef.current) return;
const errorMessage = error instanceof Error ? error.message : "读取系统更新状态失败";
setLoadError(errorMessage);
if (status && activePhases.has(status.operation.phase)) setReconnecting(true);
} finally {
if (mountedRef.current && initial) setLoading(false);
}
}, [status]);
useEffect(() => {
mountedRef.current = true;
void load(true);
return () => {
mountedRef.current = false;
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!status || !activePhases.has(status.operation.phase)) return;
const timer = window.setInterval(() => void load(false), 2500);
return () => window.clearInterval(timer);
}, [load, status]);
const operationActive = Boolean(status && activePhases.has(status.operation.phase));
const blockingCheckFailed = status?.checks.some((check) => check.blocking && check.status === "failed") ?? true;
const requestCheck = async () => {
setChecking(true);
try {
const next = await checkSystemUpdate();
setStatus(next);
message.success(next.updateAvailable ? `发现新版本 ${next.latestRelease?.version}` : "当前已是最新版本");
} catch (error) {
message.error(error instanceof Error ? error.message : "检查更新失败");
await load(false);
} finally {
setChecking(false);
}
};
const requestStart = () => {
if (!status?.latestRelease) return;
const target = status.latestRelease.version;
modal.confirm({
title: `更新到 ${target}?`,
width: 560,
content: (
<div className="space-y-3 text-sm text-foreground/70">
<p>系统会先创建并校验数据库 ZIP 与数据目录备份,再停止旧服务、执行迁移并切换镜像。</p>
<p>更新期间站点会短暂不可用。任何前置检查失败都不会切换服务;切换后验证失败会自动恢复旧版本和数据库。</p>
</div>
),
okText: "确认并开始更新",
cancelText: "取消",
onOk: async () => {
setStarting(true);
try {
const next = await startSystemUpdate(target);
setStatus(next);
message.success("更新任务已启动,页面会自动重连并显示进度");
} catch (error) {
message.error(error instanceof Error ? error.message : "无法开始更新");
throw error;
} finally {
setStarting(false);
}
},
});
};
const requestRollback = () => {
if (!status?.rollbackVersion) return;
let reason = "";
modal.confirm({
title: `回退到 ${status.rollbackVersion}?`,
width: 520,
content: (
<div className="space-y-3">
<p className="text-sm text-foreground/65">回退会停止当前服务、恢复最近一次已校验数据库备份并重新启动旧镜像。请先确认备份时间与业务影响。</p>
<Input.TextArea rows={3} maxLength={300} showCount placeholder="填写回退原因(必填)" onChange={(event) => (reason = event.target.value)} />
</div>
),
okText: "开始回退",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: async () => {
if (!reason.trim()) {
message.warning("请填写回退原因");
throw new Error("rollback reason required");
}
const next = await rollbackSystemUpdate(reason.trim());
setStatus(next);
message.success("回退任务已启动");
},
});
};
if (loading) {
return (
<AdminPageFrame title="系统更新" description="检查发布版本,并在备份、迁移和健康验证保护下完成在线更新。" scroll>
<div className="admin-settings-stack admin-system-update"><Skeleton active paragraph={{ rows: 12 }} /></div>
</AdminPageFrame>
);
}
return (
<AdminPageFrame
title="系统更新"
description="检查 GitHub Release,并在完成备份、迁移和健康验证后切换版本。"
scroll
actions={
<Button icon={<RefreshCw className="size-4" />} loading={checking} disabled={operationActive || !status?.supported} onClick={() => void requestCheck()}>
检查更新
</Button>
}
>
<div className="admin-settings-stack admin-system-update">
{loadError && !status ? <UpdateAlert tone="error" title="无法读取更新状态" detail={loadError} /> : null}
{!status?.supported ? <UpdateAlert tone="warning" title="当前部署不支持后台在线更新" detail="请先在服务器安装 Host Updater,并重建 backend 容器挂载 Unix Socket。此状态下不会执行任何更新操作。" /> : null}
{reconnecting ? <UpdateAlert tone="warning" title="服务正在切换,等待重新连接" detail="更新器运行在宿主机,后台页面暂时断线不会中止更新。连接恢复后会继续显示最终结果。" /> : null}
{status?.operation.phase === "manual_intervention" ? <UpdateAlert tone="error" title="自动回退未完成,需要人工介入" detail={status.operation.rollbackError || status.operation.error || "请检查 Host Updater 和容器日志。"} /> : null}
<div className="admin-system-update-grid">
<SettingsSectionCard
className="admin-system-update-release"
layout="stacked"
icon={<ServerCog className="size-4" />}
title="可用版本"
description={status?.updateAvailable ? "发现比当前运行版本更新的发布构建。" : "检查 GitHub Release 与当前运行版本。"}
status={<PhaseBadge phase={status?.operation.phase ?? "idle"} />}
footer={
<>
<span className="text-xs leading-5 text-foreground/50">更新按钮不会跳过备份、镜像摘要、迁移或健康检查。</span>
<Button type="primary" loading={starting} disabled={!status?.connected || !status.updateAvailable || operationActive || blockingCheckFailed} onClick={requestStart}>
开始更新
</Button>
</>
}
>
<div className="admin-system-update-release-body">
<div className="admin-system-update-version-row">
<strong>{status?.latestRelease?.version || "尚未检查"}</strong>
{status?.latestRelease?.prerelease ? <AdminStatusBadge label="预览版" tone="warning" /> : null}
</div>
<dl className="admin-system-update-facts">
<dt>当前版本</dt><dd>{status?.currentVersion || "未识别"}</dd>
<dt>部署方式</dt><dd>{status?.deployment || "未知"}</dd>
<dt>发布时间</dt><dd>{formatDate(status?.latestRelease?.publishedAt)}</dd>
<dt>代码仓库</dt><dd>{status?.repository || "ddcat-ai/open-ai-canvas"}</dd>
</dl>
{status?.latestRelease ? (
<div className="admin-system-update-notes">
<div className="flex items-center justify-between gap-3"><h3>更新日志</h3><a href={status.latestRelease.url} target="_blank" rel="noreferrer">查看 Release <ExternalLink className="size-3" /></a></div>
<pre>{status.latestRelease.body || "本版本未填写更新日志。"}</pre>
</div>
) : null}
</div>
</SettingsSectionCard>
<SettingsSectionCard layout="stacked" icon={<ShieldCheck className="size-4" />} title="更新前检查" description="所有阻断项通过后才允许切换服务。" status={{ label: status?.connected ? "更新器已连接" : "更新器未连接", color: status?.connected ? "success" : "error" }}>
<div className="admin-system-update-checks">
{(status?.checks || []).map((check) => <UpdateCheckRow key={check.key} check={check} />)}
</div>
</SettingsSectionCard>
</div>
<SettingsSectionCard layout="stacked" icon={<History className="size-4" />} title="更新进度" description="操作状态和日志由宿主机持久化,服务重启后仍可读取。" status={<PhaseBadge phase={status?.operation.phase ?? "idle"} />}>
<div className="admin-system-update-progress">
{status?.operation.error ? <UpdateAlert tone="error" title="本次操作失败" detail={status.operation.error} compact /> : null}
{(status?.operation.logs || []).length ? (
<ol>
{status?.operation.logs.map((entry, index) => (
<li key={`${entry.at}-${index}`}><span className="admin-system-update-dot" data-active={index === status.operation.logs.length - 1} /><div><strong>{phaseLabels[entry.phase]}</strong><p>{entry.message}</p></div><time>{formatTime(entry.at)}</time></li>
))}
</ol>
) : <p className="admin-system-update-empty">检查更新后,这里会显示完整操作记录。</p>}
</div>
</SettingsSectionCard>
<SettingsSectionCard
layout="stacked"
icon={<Archive className="size-4" />}
title="备份与回退"
description="只使用最近一次通过 ZIP 结构与 SHA-256 校验的备份。"
status={{ label: status?.lastBackup ? "备份已校验" : "暂无备份", color: status?.lastBackup ? "success" : "warning" }}
footer={
<>
<span className="text-xs text-foreground/50">人工回退同样会执行停服、数据库恢复、旧镜像启动和健康验证。</span>
<Button danger icon={<RotateCcw className="size-4" />} disabled={!status?.rollbackVersion || operationActive || !status?.connected} onClick={requestRollback}>回退到上一版本</Button>
</>
}
>
<div className="admin-system-update-backup">
{status?.lastBackup ? <dl><dt>备份编号</dt><dd>{status.lastBackup.id}</dd><dt>来源版本</dt><dd>{status.lastBackup.version}</dd><dt>创建时间</dt><dd>{formatDate(status.lastBackup.createdAt)}</dd><dt>文件大小</dt><dd>{formatBytes(status.lastBackup.size)}</dd><dt>SHA-256</dt><dd className="admin-monospace">{status.lastBackup.checksum}</dd></dl> : <p>首次开始更新时会自动创建数据库与数据目录 ZIP;备份失败将直接中止更新。</p>}
</div>
</SettingsSectionCard>
</div>
</AdminPageFrame>
);
}
function PhaseBadge({ phase }: { phase: UpdatePhase }) {
const tone = phase === "succeeded" || phase === "ready" ? "success" : phase === "failed" || phase === "manual_intervention" ? "error" : phase === "rolling_back" || phase === "rolled_back" ? "warning" : activePhases.has(phase) ? "info" : "neutral";
return <AdminStatusBadge label={phaseLabels[phase]} tone={tone} />;
}
function UpdateCheckRow({ check }: { check: SystemUpdateStatus["checks"][number] }) {
const Icon = check.status === "passed" ? BadgeCheck : check.status === "failed" ? AlertTriangle : Circle;
return <div className="admin-system-update-check-row" data-status={check.status}><Icon className="size-4" /><div><strong>{check.label}</strong><p>{check.detail || "等待执行"}</p></div><span>{check.status === "passed" ? "通过" : check.status === "failed" ? "失败" : "待执行"}</span></div>;
}
function UpdateAlert({ tone, title, detail, compact = false }: { tone: "warning" | "error"; title: string; detail: string; compact?: boolean }) {
return <div className={`admin-system-update-alert${compact ? " is-compact" : ""}`} data-tone={tone} role="alert"><AlertTriangle className="size-4" /><div><strong>{title}</strong><p>{detail}</p></div></div>;
}
function formatDate(value?: string) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("zh-CN", { hour12: false });
}
function formatTime(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleTimeString("zh-CN", { hour12: false });
}
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) return "—";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / 1024 ** index).toFixed(index > 1 ? 2 : 0)} ${units[index]}`;
}

View File

@@ -28,6 +28,7 @@ const StorageSettingsPage = lazy(() => import("@/pages/admin/settings/storage-se
const ArkPrivateAssetsSettingsPage = lazy(() => import("@/pages/admin/settings/ark-private-assets-settings-page"));
const ResponseInterceptionSettingsPage = lazy(() => import("@/pages/admin/settings/response-interception-settings-page"));
const ThirdPartySettingsPage = lazy(() => import("@/pages/admin/settings/libtv-settings-page"));
const SystemUpdatePage = lazy(() => import("@/pages/admin/settings/system-update-page"));
const StoryboardPromptsPage = lazy(() => import("@/pages/admin/storyboard-prompts/storyboard-prompts-page"));
const UsersPage = lazy(() => import("@/pages/admin/users/users-page"));
const AssetsPage = lazy(loadAssetsPage);
@@ -187,6 +188,7 @@ export const router = createBrowserRouter([
{ path: "settings/ark-private-assets", element: deferred(<ArkPrivateAssetsSettingsPage />) },
{ path: "settings/response-interception", element: deferred(<ResponseInterceptionSettingsPage />) },
{ path: "settings/third-party", element: deferred(<ThirdPartySettingsPage />) },
{ path: "settings/system-update", element: deferred(<SystemUpdatePage />) },
{ path: "settings/libtv", element: <Navigate to="/admin/settings/third-party" replace /> },
],
},

View File

@@ -0,0 +1,94 @@
import { apiClient, request } from "./request";
export type UpdatePhase =
| "idle"
| "checking"
| "ready"
| "no_update"
| "preflight"
| "backing_up"
| "pulling"
| "draining"
| "migrating"
| "switching"
| "verifying"
| "succeeded"
| "rolling_back"
| "rolled_back"
| "failed"
| "manual_intervention";
export type SystemUpdateRelease = {
version: string;
name: string;
body: string;
url: string;
publishedAt: string;
prerelease: boolean;
};
export type SystemUpdateCheck = {
key: string;
label: string;
status: "passed" | "pending" | "failed" | string;
detail?: string;
blocking: boolean;
};
export type SystemUpdateBackup = {
id: string;
path: string;
checksum: string;
size: number;
createdAt: string;
version: string;
};
export type SystemUpdateLog = {
at: string;
phase: UpdatePhase;
message: string;
};
export type SystemUpdateOperation = {
id?: string;
phase: UpdatePhase;
fromVersion?: string;
targetVersion?: string;
startedAt?: string;
finishedAt?: string;
error?: string;
rollbackError?: string;
automaticRollback: boolean;
logs: SystemUpdateLog[];
};
export type SystemUpdateStatus = {
supported: boolean;
connected: boolean;
repository: string;
deployment: string;
currentVersion: string;
latestRelease?: SystemUpdateRelease;
updateAvailable: boolean;
checks: SystemUpdateCheck[];
lastBackup?: SystemUpdateBackup;
rollbackVersion?: string;
operation: SystemUpdateOperation;
};
export function getSystemUpdateStatus(signal?: AbortSignal) {
return request<SystemUpdateStatus>(apiClient.get("/admin/system-update", { signal }));
}
export function checkSystemUpdate() {
return request<SystemUpdateStatus>(apiClient.post("/admin/system-update/check"));
}
export function startSystemUpdate(targetVersion: string) {
return request<SystemUpdateStatus>(apiClient.post("/admin/system-update/start", { targetVersion }));
}
export function rollbackSystemUpdate(reason: string) {
return request<SystemUpdateStatus>(apiClient.post("/admin/system-update/rollback", { reason }));
}

View File

@@ -8676,6 +8676,241 @@ html[data-magicui-theme-vt="active"] .admin-shell * {
color: var(--foreground);
}
.admin-system-update {
gap: 14px;
padding-bottom: 28px;
}
.admin-system-update-grid {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.75fr);
gap: 14px;
}
.admin-system-update-release-body,
.admin-system-update-progress,
.admin-system-update-backup {
padding: 16px;
}
.admin-system-update-version-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding-bottom: 15px;
}
.admin-system-update-version-row > strong {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: clamp(22px, 3vw, 29px);
line-height: 1;
letter-spacing: -0.04em;
}
.admin-system-update-facts,
.admin-system-update-backup dl {
display: grid;
grid-template-columns: 104px minmax(0, 1fr);
gap: 8px 14px;
margin: 0;
font-size: 12px;
}
.admin-system-update-facts dt,
.admin-system-update-backup dt {
color: color-mix(in srgb, var(--foreground) 48%, transparent);
}
.admin-system-update-facts dd,
.admin-system-update-backup dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.admin-system-update-notes {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--admin-divider);
}
.admin-system-update-notes h3 {
font-size: 12px;
font-weight: 650;
}
.admin-system-update-notes a {
display: inline-flex;
align-items: center;
gap: 4px;
color: color-mix(in srgb, var(--foreground) 62%, transparent);
font-size: 11px;
}
.admin-system-update-notes pre {
max-height: 260px;
margin: 10px 0 0;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: color-mix(in srgb, var(--foreground) 68%, transparent);
font: inherit;
font-size: 12px;
line-height: 1.7;
}
.admin-system-update-checks {
padding: 4px 16px 8px;
}
.admin-system-update-check-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: start;
gap: 9px;
padding: 11px 0;
border-bottom: 1px solid var(--admin-divider);
}
.admin-system-update-check-row:last-child {
border-bottom: 0;
}
.admin-system-update-check-row > svg {
margin-top: 1px;
color: color-mix(in srgb, var(--foreground) 38%, transparent);
}
.admin-system-update-check-row[data-status="passed"] > svg,
.admin-system-update-check-row[data-status="passed"] > span {
color: var(--status-success);
}
.admin-system-update-check-row[data-status="failed"] > svg,
.admin-system-update-check-row[data-status="failed"] > span {
color: var(--status-error);
}
.admin-system-update-check-row strong {
display: block;
font-size: 12px;
font-weight: 600;
}
.admin-system-update-check-row p {
margin: 2px 0 0;
color: color-mix(in srgb, var(--foreground) 48%, transparent);
font-size: 11px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.admin-system-update-check-row > span {
color: color-mix(in srgb, var(--foreground) 45%, transparent);
font-size: 11px;
}
.admin-system-update-progress ol {
display: grid;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.admin-system-update-progress li {
display: grid;
grid-template-columns: 14px minmax(0, 1fr) auto;
gap: 9px;
padding: 9px 0;
}
.admin-system-update-progress li strong {
font-size: 12px;
font-weight: 600;
}
.admin-system-update-progress li p {
margin: 2px 0 0;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
font-size: 12px;
}
.admin-system-update-progress time {
color: color-mix(in srgb, var(--foreground) 40%, transparent);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 10px;
}
.admin-system-update-dot {
width: 8px;
height: 8px;
margin-top: 5px;
border-radius: 50%;
background: color-mix(in srgb, var(--foreground) 24%, transparent);
}
.admin-system-update-dot[data-active="true"] {
background: var(--status-loading);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--status-loading) 12%, transparent);
}
.admin-system-update-empty,
.admin-system-update-backup > p {
margin: 0;
color: color-mix(in srgb, var(--foreground) 52%, transparent);
font-size: 12px;
line-height: 1.7;
}
.admin-system-update-alert {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 10px;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--status-warning) 26%, var(--admin-divider));
border-radius: 8px;
background: color-mix(in srgb, var(--status-warning) 7%, var(--background));
}
.admin-system-update-alert[data-tone="error"] {
border-color: color-mix(in srgb, var(--status-error) 28%, var(--admin-divider));
background: color-mix(in srgb, var(--status-error) 7%, var(--background));
}
.admin-system-update-alert > svg {
margin-top: 1px;
color: var(--status-warning);
}
.admin-system-update-alert[data-tone="error"] > svg {
color: var(--status-error);
}
.admin-system-update-alert strong {
display: block;
font-size: 12px;
}
.admin-system-update-alert p {
margin: 3px 0 0;
color: color-mix(in srgb, var(--foreground) 62%, transparent);
font-size: 12px;
line-height: 1.6;
}
.admin-system-update-alert.is-compact {
margin-bottom: 10px;
padding: 10px 12px;
}
@media (max-width: 980px) {
.admin-system-update-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 639px) {
.admin-page-frame {
padding: 10px 12px 0;