123 lines
14 KiB
TypeScript
123 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { AlertCircle, CheckCircle2, Download, RefreshCw, Server, ShieldCheck, Terminal, Zap } from "lucide-react";
|
|
import { Button, Dialog, Tag } from "tdesign-react";
|
|
import { ApiError, api } from "../../services/api";
|
|
import { dateText } from "../expenses/date";
|
|
import { ErrorBanner, Page, Surface } from "../common";
|
|
import type { Notify } from "../expenses/types";
|
|
|
|
type JobStatus = "queued" | "downloading" | "verifying" | "staged" | "backing_up" | "applying" | "completed" | "failed" | "cancelled";
|
|
type UpdateJob = { id: string; operation?: "download" | "apply"; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; errorMessage?: string | null; createdAt?: number; updatedAt?: number; completedAt?: number | null; applyQueuedAt?: number | string | null; restartWindowSeconds?: number | null; restartDeadline?: number | string | null; restartAt?: number | string | null; expectedRecoveryAt?: number | string | null };
|
|
type LatestRelease = { version: string; tagName?: string; releaseName?: string; publishedAt?: string; compatible: boolean; integrityReady: boolean; signatureReady: boolean; isNewer: boolean; assetName?: string; assetSize?: number; notes?: string | null; releaseNotes?: string | null; body?: string | null; htmlUrl?: string | null };
|
|
type UpdateInfo = { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; platform: { target: string; os: string; arch: string }; checkedAt: number; latest: LatestRelease | null; job: UpdateJob | null };
|
|
const active = new Set<JobStatus>(["queued", "downloading", "verifying", "staged", "backing_up", "applying"]);
|
|
const pollable = new Set<JobStatus>(["queued", "downloading", "verifying", "backing_up", "applying"]);
|
|
const labels: Record<JobStatus, string> = { queued: "等待系统服务", downloading: "下载中", verifying: "校验文件", staged: "下载完成,等待应用", backing_up: "备份数据", applying: "切换并检查服务", completed: "已完成", failed: "失败", cancelled: "已取消" };
|
|
|
|
function timestamp(value: number | string | null | undefined): number | null {
|
|
if (value === null || value === undefined || value === "") return null;
|
|
const n = typeof value === "number" ? value : Date.parse(value);
|
|
if (!Number.isFinite(n)) return null;
|
|
return n < 10_000_000_000 ? n * 1000 : n;
|
|
}
|
|
function notesFor(latest: LatestRelease): string | null {
|
|
const value = latest.notes ?? latest.releaseNotes ?? latest.body;
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { timezone?: string; notify?: Notify }) {
|
|
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [checking, setChecking] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [pollError, setPollError] = useState("");
|
|
const [confirmVersion, setConfirmVersion] = useState<string | null>(null);
|
|
const [confirmAction, setConfirmAction] = useState<"download" | "apply">("download");
|
|
const [actionBusy, setActionBusy] = useState(false);
|
|
const [reloadReady, setReloadReady] = useState(false);
|
|
const [now, setNow] = useState(() => Date.now());
|
|
const announced = useRef<string | null>(null);
|
|
const checkInFlight = useRef(false);
|
|
const actionInFlight = useRef(false);
|
|
const disconnected = useRef(false);
|
|
const recoveredNotice = useRef(false);
|
|
|
|
const load = async () => { setLoading(true); setError(""); try { setInfo(await api<UpdateInfo>("/api/update/status")); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } };
|
|
useEffect(() => { void load(); }, []);
|
|
const job = info?.job;
|
|
const applyQueuedAt = timestamp(job?.applyQueuedAt);
|
|
const restartAt = timestamp(job?.restartDeadline)
|
|
?? timestamp(job?.restartAt)
|
|
?? timestamp(job?.expectedRecoveryAt)
|
|
?? (job?.operation === "apply" && applyQueuedAt ? applyQueuedAt + (job.restartWindowSeconds ?? 30) * 1000 : null)
|
|
?? (job?.status === "applying" && job.updatedAt ? timestamp(job.updatedAt)! + 30_000 : null);
|
|
const restartSeconds = restartAt ? Math.max(0, Math.ceil((restartAt - now) / 1000)) : null;
|
|
useEffect(() => { if (!restartAt) return; const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, [restartAt]);
|
|
|
|
useEffect(() => {
|
|
const shouldPoll = Boolean(job && (pollable.has(job.status) || (job.status === "staged" && job.operation === "apply")));
|
|
if (!shouldPoll || !job) { setPollError(""); return; }
|
|
let disposed = false; let timer: number | undefined; let failures = 0;
|
|
const schedule = (delay: number) => { timer = window.setTimeout(() => void poll(), delay); };
|
|
const poll = async () => {
|
|
try {
|
|
const result = await api<{ job: UpdateJob }>(`/api/update/jobs/${job.id}`);
|
|
if (disposed) return;
|
|
failures = 0;
|
|
if (disconnected.current && !recoveredNotice.current) { recoveredNotice.current = true; notify?.("服务已恢复,更新状态已刷新", "success"); }
|
|
disconnected.current = false; setPollError("");
|
|
setInfo(current => current ? { ...current, job: result.job } : current);
|
|
if (result.job.status === "completed" && announced.current !== result.job.id) { announced.current = result.job.id; setReloadReady(true); notify?.("更新完成,请重新加载页面", "success"); }
|
|
if (pollable.has(result.job.status) || (result.job.status === "staged" && result.job.operation === "apply")) schedule(1500);
|
|
} catch {
|
|
if (disposed) return;
|
|
failures += 1; disconnected.current = true; recoveredNotice.current = false;
|
|
setPollError(`服务暂时不可用${restartSeconds !== null ? `,预计 ${restartSeconds} 秒后恢复` : ",页面会自动重试"}。更新任务仍在后台运行。`);
|
|
schedule(Math.min(1500 * (2 ** Math.min(failures, 3)), 12_000));
|
|
}
|
|
};
|
|
void poll();
|
|
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); };
|
|
}, [job?.id, job?.status, job?.operation, notify]);
|
|
|
|
const check = async () => {
|
|
if (checkInFlight.current) return;
|
|
checkInFlight.current = true; setChecking(true); setError("");
|
|
try {
|
|
const result = await api<Omit<UpdateInfo, "job"> & { job?: UpdateJob | null }>("/api/update/check", { method: "POST", body: "{}" });
|
|
setInfo(current => ({ ...result, job: result.job ?? current?.job ?? null })); notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
|
|
} catch (caught) {
|
|
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") { try { await load(); notify?.(`已显示最近一次检查结果${caught.retryAfter ? `,请 ${caught.retryAfter} 秒后再检查` : ",请稍后再检查"}`, "info"); return; } catch { /* fall through */ } }
|
|
setError((caught as Error).message);
|
|
} finally { checkInFlight.current = false; setChecking(false); }
|
|
};
|
|
const submitAction = async () => {
|
|
if (!confirmVersion || actionInFlight.current) return;
|
|
actionInFlight.current = true; setActionBusy(true); setError("");
|
|
try {
|
|
const endpoint = confirmAction === "download" ? "/api/update/download" : "/api/update/apply";
|
|
const body = confirmAction === "download" ? { version: confirmVersion, confirm: true } : { jobId: job?.id, version: confirmVersion, confirm: true };
|
|
const result = await api<{ job: UpdateJob }>(endpoint, { method: "POST", body: JSON.stringify(body) });
|
|
setConfirmVersion(null); setReloadReady(false); setInfo(current => current ? { ...current, job: result.job } : current); notify?.(confirmAction === "download" ? "更新包下载已开始" : "更新已开始,服务会短暂重启", "info");
|
|
} catch (caught) { setError((caught as Error).message); } finally { actionInFlight.current = false; setActionBusy(false); }
|
|
};
|
|
|
|
const latest = info?.latest; const hasActiveJob = Boolean(job && active.has(job.status));
|
|
const sameCompleted = Boolean(job?.status === "completed" && latest && job.version === latest.version);
|
|
const canDownload = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && !sameCompleted && (!job || job.version !== latest.version || job.status === "failed" || job.status === "cancelled"));
|
|
const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && job?.operation === "download" && job.status === "staged" && job.version === latest.version);
|
|
const progress = job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial<Record<JobStatus, number>>)[job.status] ?? 100 : 0;
|
|
const notes = latest ? notesFor(latest) : null;
|
|
|
|
return <Page title="系统更新" subtitle="检查受信任的 Release;更新前会校验文件并保护现有数据。" actions={<Button variant="outline" onClick={() => void check()} disabled={checking || loading || hasActiveJob} icon={<RefreshCw size={15} />}>{checking ? "检查中…" : "检查更新"}</Button>}>
|
|
{error && <ErrorBanner message={error} onRetry={() => void load()} />}{pollError && <ErrorBanner message={pollError} />}
|
|
{loading ? <div className="tn-empty" role="status" aria-live="polite">正在读取版本信息…</div> : info && <>
|
|
<div className="tn-update-grid"><Surface className="tn-update-block"><Server size={20} /><span className="tn-eyebrow">当前版本</span><strong className="tn-update-value">v{info.currentVersion}</strong><small>运行平台:{info.platform.target}</small></Surface><Surface className="tn-update-block"><ShieldCheck size={20} /><span className="tn-eyebrow">更新方式</span><strong>{info.strategy === "systemd" ? "后台一键更新" : "手动命令行更新"}</strong><small>{info.strategy === "systemd" ? (info.configured ? "由 systemd 更新服务执行" : "尚未配置发布源") : "当前安装未启用后台更新"}</small></Surface></div>
|
|
{latest ? <Surface className="tn-update-release"><div className="tn-update-release-head"><div><span className="tn-eyebrow">最新 Release</span><h2>{latest.releaseName || latest.tagName || `v${latest.version}`}</h2>{latest.publishedAt && <small>发布时间:{dateText(Date.parse(latest.publishedAt), timezone)}</small>}</div><Tag theme={latest.isNewer ? "primary" : "success"}>{latest.isNewer ? "有新版本" : "已是最新"}</Tag></div>{notes && <div className="tn-release-notes"><span className="tn-eyebrow">Release notes</span><div>{notes}</div></div>}<div className="tn-facts"><div><span>平台文件</span><strong>{latest.compatible ? latest.assetName : "无匹配文件"}</strong></div><div><span>完整性</span><strong className={latest.integrityReady ? "text-success" : "text-danger"}>{latest.integrityReady ? "SHA-256 + 签名可验证" : "不可验证"}</strong></div><div><span>文件大小</span><strong>{latest.assetSize ? `${(latest.assetSize / 1024 / 1024).toFixed(1)} MB` : "-"}</strong></div></div>{latest.isNewer && !latest.compatible && <div className="tn-inline-error"><AlertCircle size={16} />当前平台没有可安装的 Release 文件。</div>}{latest.isNewer && latest.compatible && !latest.integrityReady && <div className="tn-inline-error"><AlertCircle size={16} />发布文件缺少完整校验,已禁用更新。</div>}<div className="tn-page-actions">{canDownload && <Button theme="primary" onClick={() => { setConfirmAction("download"); setConfirmVersion(latest.version); }} disabled={actionBusy} loading={actionBusy && confirmAction === "download"} icon={<Download size={16} />}>下载更新包</Button>}{canApply && <Button theme="primary" onClick={() => { setConfirmAction("apply"); setConfirmVersion(latest.version); }} disabled={actionBusy} loading={actionBusy && confirmAction === "apply"} icon={<Zap size={16} />}>立即更新</Button>}{reloadReady && <Button theme="primary" onClick={() => window.location.reload()}>重新加载</Button>}</div></Surface> : <div className="tn-empty">点击“检查更新”获取最新 Release。</div>}
|
|
{!info.configured && <div className="tn-update-explainer"><Terminal size={17} /><div><strong>当前为手动更新模式</strong><p>源码安装默认不启用后台更新。需要更新时,在服务器拉取对应 Release 后重新构建并重启服务;安装器部署并配置 systemd 后,才会显示后台一键更新。</p></div></div>}
|
|
{job && <Surface className="tn-update-release"><span className="tn-sr-only" aria-live="polite">更新任务状态:{labels[job.status]}</span><div className="tn-update-release-head"><div><span className="tn-eyebrow">最近任务</span><h2>v{job.version}</h2></div><Tag theme={job.status === "completed" ? "success" : job.status === "failed" ? "danger" : "primary"}>{labels[job.status]}</Tag></div>{active.has(job.status) && <><div className="tn-progress" role="progressbar" aria-label="系统更新进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress}><span style={{ width: `${progress}%` }} /></div><small>{job.status === "staged" && job.operation === "download" ? "更新包已下载并校验,可以立即应用。" : job.status === "staged" && job.operation === "apply" ? "立即更新请求已提交,服务即将重启。" : "更新服务正在后台运行,页面会自动刷新状态。"}</small>{restartSeconds !== null && (job.status === "applying" || disconnected.current) && <div className="tn-restart-countdown" role="status">服务正在重启,预计 {restartSeconds} 秒后恢复</div>}</>}{job.status === "failed" && job.errorMessage && <div className="tn-inline-error" role="alert">{job.errorMessage}</div>}{job.status === "completed" && <div className="tn-inline-info"><CheckCircle2 size={16} />新版本已通过健康检查,数据和附件保持不变。</div>}</Surface>}
|
|
</>}
|
|
<Dialog visible={Boolean(confirmVersion)} header={confirmAction === "download" ? "下载更新包" : "确认立即更新"} confirmBtn={{ content: confirmAction === "download" ? "开始下载" : "立即更新", theme: "primary", loading: actionBusy, disabled: actionBusy }} cancelBtn="取消" onClose={() => { if (!actionBusy) setConfirmVersion(null); }} onConfirm={() => void submitAction()} onCancel={() => { if (!actionBusy) setConfirmVersion(null); }}>{confirmAction === "download" ? `将下载并校验 v${confirmVersion},完成后可选择立即更新。` : `将应用已下载的 v${confirmVersion}。服务会短暂重启,更新前会备份数据目录。`}</Dialog>
|
|
</Page>;
|
|
}
|