This commit is contained in:
@@ -276,8 +276,8 @@ export default function UpdatePage({
|
||||
});
|
||||
};
|
||||
|
||||
const load = async (showLoading = true) => {
|
||||
if (loadInFlight.current) return;
|
||||
const load = async (showLoading = true): Promise<UpdateInfo | null> => {
|
||||
if (loadInFlight.current) return null;
|
||||
loadInFlight.current = true;
|
||||
if (showLoading) setLoading(true);
|
||||
setError("");
|
||||
@@ -285,18 +285,16 @@ export default function UpdatePage({
|
||||
const res = await api<UpdateInfo>("/api/update/status");
|
||||
mergeInfo(res);
|
||||
updateInfoCache = res;
|
||||
return res;
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
return null;
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
loadInFlight.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
// Keep terminal jobs visible so operators can understand what happened and
|
||||
// recover without guessing. The polling effect below only polls active jobs.
|
||||
const job = info?.job ?? null;
|
||||
@@ -467,6 +465,24 @@ export default function UpdatePage({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const bootstrap = async () => {
|
||||
const snapshot = await load();
|
||||
if (disposed || !snapshot) return;
|
||||
// Status may be satisfied from the release cache. Refresh it on entry
|
||||
// only when the cached result is absent or older than one minute; this
|
||||
// keeps the page current without turning navigation into a burst of
|
||||
// rate-limited checks.
|
||||
const checkedAt = snapshot.checkedAt || 0;
|
||||
if (!checkedAt || !snapshot.latest || Date.now() - checkedAt > 60_000) await check();
|
||||
};
|
||||
void bootstrap();
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cancel queued job handler
|
||||
const cancelJob = async () => {
|
||||
if (cancelInFlight.current || !job?.id) return;
|
||||
@@ -539,9 +555,20 @@ export default function UpdatePage({
|
||||
|
||||
const latest = info?.latest;
|
||||
const notes = notesFor(latest);
|
||||
const hasChecked = Boolean(info?.checkedAt);
|
||||
const releaseState = checking
|
||||
? "checking"
|
||||
: !hasChecked
|
||||
? "unverified"
|
||||
: !latest
|
||||
? "unavailable"
|
||||
: latest.isNewer
|
||||
? latest.compatible && latest.integrityReady ? "available" : "blocked"
|
||||
: "up-to-date";
|
||||
|
||||
const canDownload = Boolean(
|
||||
info?.strategy === "systemd" &&
|
||||
!checking &&
|
||||
latest?.isNewer &&
|
||||
latest.compatible &&
|
||||
latest.integrityReady &&
|
||||
@@ -550,14 +577,19 @@ export default function UpdatePage({
|
||||
|
||||
const canApply = Boolean(
|
||||
info?.strategy === "systemd" &&
|
||||
!checking &&
|
||||
job &&
|
||||
job.status === "staged" &&
|
||||
job.operation === "download"
|
||||
job.operation === "download" &&
|
||||
latest?.isNewer &&
|
||||
latest.version === job.version &&
|
||||
job.version !== info.currentVersion
|
||||
);
|
||||
|
||||
const hasActiveJob = Boolean(
|
||||
job && activeStatuses.has(job.status) && job.status !== "staged"
|
||||
job && activeStatuses.has(job.status) && !(job.status === "staged" && job.operation === "download")
|
||||
);
|
||||
const showJobDetails = hasActiveJob || canApply || Boolean(job?.status === "staged" && job.operation === "apply");
|
||||
|
||||
// Compute current pipeline step index (0: check, 1: download, 2: verify/stage, 3: apply/restart)
|
||||
const currentStep = useMemo(() => {
|
||||
@@ -614,7 +646,7 @@ export default function UpdatePage({
|
||||
subtitle="管理系统版本升级、更新包完整性校验与安全热重启"
|
||||
actions={
|
||||
<div className="tn-update-page-actions">
|
||||
{hasActiveJob && (
|
||||
{showJobDetails && (
|
||||
<Button
|
||||
theme="primary"
|
||||
variant="base"
|
||||
@@ -685,10 +717,18 @@ export default function UpdatePage({
|
||||
<span className="tn-metric-label">当前运行版本</span>
|
||||
<div className="tn-metric-value">v{info.currentVersion}</div>
|
||||
<div className="tn-metric-foot">
|
||||
{latest?.isNewer ? (
|
||||
<Tag theme="primary" size="small">可更新至 v{latest.version}</Tag>
|
||||
) : (
|
||||
{releaseState === "checking" ? (
|
||||
<Tag theme="default" size="small">正在检查更新</Tag>
|
||||
) : releaseState === "unverified" ? (
|
||||
<Tag theme="default" size="small">尚未检查更新</Tag>
|
||||
) : releaseState === "available" ? (
|
||||
<Tag theme="primary" size="small">可更新至 v{latest?.version}</Tag>
|
||||
) : releaseState === "up-to-date" ? (
|
||||
<Tag theme="success" size="small">已是最新版本</Tag>
|
||||
) : releaseState === "blocked" ? (
|
||||
<Tag theme="warning" size="small">发现新版本,但暂不可更新</Tag>
|
||||
) : (
|
||||
<Tag theme="default" size="small">暂未获取发布信息</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Surface>
|
||||
@@ -727,8 +767,8 @@ export default function UpdatePage({
|
||||
<Surface className="tn-ascii-release-container">
|
||||
<div className="tn-ascii-release-head">
|
||||
<h3 className="tn-ascii-release-title">发布版本详情</h3>
|
||||
<Tag theme={latest.isNewer ? "primary" : "success"} variant="light-outline">
|
||||
{latest.isNewer ? "发现新版本" : "已是最新版本"}
|
||||
<Tag theme={releaseState === "available" ? "primary" : releaseState === "up-to-date" ? "success" : releaseState === "blocked" ? "warning" : "default"} variant="light-outline">
|
||||
{releaseState === "available" ? "发现新版本" : releaseState === "up-to-date" ? "已是最新版本" : releaseState === "blocked" ? "暂不可安全更新" : "尚未检查"}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
@@ -780,7 +820,7 @@ export default function UpdatePage({
|
||||
disabled={actionBusy}
|
||||
icon={<Download size={16} />}
|
||||
>
|
||||
立即升级至 v{latest.version}
|
||||
下载更新包
|
||||
</Button>
|
||||
)}
|
||||
{canApply && (
|
||||
@@ -791,7 +831,7 @@ export default function UpdatePage({
|
||||
disabled={actionBusy}
|
||||
icon={<Zap size={16} />}
|
||||
>
|
||||
更新包已就绪,立即应用 (v{latest.version})
|
||||
立即应用并重启 v{latest.version}
|
||||
</Button>
|
||||
)}
|
||||
{hasActiveJob && (
|
||||
@@ -804,9 +844,14 @@ export default function UpdatePage({
|
||||
查看当前升级进度
|
||||
</Button>
|
||||
)}
|
||||
{!latest.isNewer && !hasActiveJob && (
|
||||
{releaseState === "blocked" && !hasActiveJob && !canApply && (
|
||||
<Button theme="default" variant="outline" size="large" onClick={() => void check()} loading={checking} disabled={checking} icon={<RefreshCw size={15} />}>
|
||||
重新检查
|
||||
</Button>
|
||||
)}
|
||||
{releaseState === "up-to-date" && !hasActiveJob && !canApply && (
|
||||
<Button theme="default" variant="outline" size="large" onClick={() => void check()} icon={<RefreshCw size={15} />}>
|
||||
检查新版本
|
||||
重新检查
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -814,10 +859,10 @@ export default function UpdatePage({
|
||||
) : (
|
||||
<Surface className="tn-empty-surface">
|
||||
<div className="tn-empty-content">
|
||||
<CheckCircle2 size={32} className="text-success" />
|
||||
<p>暂无待更新的版本信息,当前系统已是最新状态。</p>
|
||||
<Button variant="outline" onClick={() => void check()} icon={<RefreshCw size={15} />}>
|
||||
检查新版本
|
||||
{releaseState === "unverified" || releaseState === "checking" ? <RefreshCw size={32} className={releaseState === "checking" ? "tn-spin" : "text-secondary"} /> : <AlertCircle size={32} className="text-warning" />}
|
||||
<p>{releaseState === "unverified" || releaseState === "checking" ? "尚未完成版本检查,请先获取官方发布信息。" : "暂时没有可用的发布信息,请稍后重新检查。"}</p>
|
||||
<Button variant="outline" onClick={() => void check()} loading={checking} disabled={checking} icon={<RefreshCw size={15} />}>
|
||||
{releaseState === "checking" ? "正在检查" : "检查新版本"}
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
@@ -826,7 +871,7 @@ export default function UpdatePage({
|
||||
{latest && notes && (
|
||||
<Surface className="tn-ascii-notes-container">
|
||||
<div className="tn-ascii-notes-head">
|
||||
<h3 className="tn-ascii-notes-title">本次版本更新说明</h3>
|
||||
<h3 className="tn-ascii-notes-title">发布说明</h3>
|
||||
</div>
|
||||
<div className="tn-ascii-notes-body">
|
||||
<MarkdownNotes value={notes} />
|
||||
@@ -851,7 +896,7 @@ export default function UpdatePage({
|
||||
{/* Unified Single Upgrade Modal (800px width on desktop) */}
|
||||
<Dialog
|
||||
visible={showUpgradeModal}
|
||||
header={`系统升级控制台 · v${latest?.version || ""}`}
|
||||
header={`系统升级控制台 · v${latest?.version || job?.version || ""}`}
|
||||
className="tn-dialog-large"
|
||||
width="820px"
|
||||
footer={null}
|
||||
@@ -983,7 +1028,7 @@ export default function UpdatePage({
|
||||
)}
|
||||
|
||||
{/* 场景 C: 校验通过准备就绪 (staged) (Exact ASCII) */}
|
||||
{job?.status === "staged" && (
|
||||
{job?.status === "staged" && job.operation === "download" && canApply && (
|
||||
<div className="tn-modal-card-box">
|
||||
<div className="tn-modal-card-title" style={{ color: "#2f7d5c", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<CheckCircle2 size={18} />
|
||||
@@ -1011,6 +1056,36 @@ export default function UpdatePage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A staged archive can become obsolete when the host or release
|
||||
metadata changes while this page is open. Keep the state visible
|
||||
but remove the apply action; the server will reconcile it on the
|
||||
next status request and the operator can perform a fresh check. */}
|
||||
{job?.status === "staged" && job.operation === "download" && !canApply && (
|
||||
<div className="tn-modal-card-box">
|
||||
<div className="tn-modal-card-title">暂存更新已失效</div>
|
||||
<p className="text-secondary" style={{ fontSize: "13px", margin: "0 0 16px", lineHeight: 1.6 }}>
|
||||
这个更新包已不是当前可安全应用的版本,系统不会重复应用。请重新检查更新以获取最新发布信息。
|
||||
</p>
|
||||
<div className="tn-modal-actions-bar">
|
||||
<Button variant="outline" onClick={() => void check()} loading={checking} disabled={checking}>
|
||||
重新检查
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job?.status === "staged" && job.operation === "apply" && (
|
||||
<div className="tn-modal-card-box">
|
||||
<div className="tn-modal-card-title">
|
||||
<div style={{ marginBottom: 12 }}><BeamBar width={180} /></div>
|
||||
应用请求已提交,正在等待更新服务接管
|
||||
</div>
|
||||
<p className="text-secondary" style={{ fontSize: "13px", margin: "0 0 16px", lineHeight: 1.6 }}>
|
||||
系统正在准备备份与重启。页面会持续同步服务状态,请不要重复提交。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 场景 D: 数据快照备份中或服务重启中 (backing_up / applying) (Exact ASCII) */}
|
||||
{(job?.status === "backing_up" || job?.status === "applying") && (
|
||||
<div className="tn-modal-card-box">
|
||||
@@ -1072,7 +1147,7 @@ export default function UpdatePage({
|
||||
)}
|
||||
|
||||
{/* Footer close button */}
|
||||
{job?.status !== "staged" && job?.status !== "completed" && !confirmReadyToDownload && (
|
||||
{!(job?.status === "staged" && canApply) && job?.status !== "completed" && !confirmReadyToDownload && (
|
||||
<div className="tn-modal-footer-close">
|
||||
<Button variant="outline" onClick={() => setShowUpgradeModal(false)}>
|
||||
关闭窗口(后台继续运行)
|
||||
|
||||
Reference in New Issue
Block a user