fix: make online updates recoverable
TallyNote release / linux-x64 (push) Successful in 7m45s

This commit is contained in:
Qiufeng
2026-09-05 14:56:15 +08:00
parent a080f531cd
commit ed0b492461
12 changed files with 526 additions and 165 deletions
+113 -17
View File
@@ -248,22 +248,48 @@ export default function UpdatePage({
const announced = useRef<string | null>(null);
const checkInFlight = useRef(false);
const actionInFlight = useRef(false);
const cancelInFlight = useRef(false);
const loadInFlight = useRef(false);
const disconnected = useRef(false);
const recoveredNotice = useRef(false);
const info = liveInfo;
const load = async () => {
setLoading(true);
const mergeInfo = (incoming: UpdateInfo, preserveJob = false) => {
setLiveInfo((current) => {
if (!current) return incoming;
const next = {
...current,
...incoming,
// A check response describes the release, but must not erase the job
// that is already being displayed while that check is in flight.
job: preserveJob
? (current.job ?? incoming.job)
: (!Object.prototype.hasOwnProperty.call(incoming, "job") ? current.job : incoming.job),
};
// Some status/check responses are intentionally partial. Keep fields
// from the last successful response when a field is omitted.
if (!Object.prototype.hasOwnProperty.call(incoming, "latest")) next.latest = current.latest;
if (!Object.prototype.hasOwnProperty.call(incoming, "checkedAt")) next.checkedAt = current.checkedAt;
if (!Object.prototype.hasOwnProperty.call(incoming, "strategy")) next.strategy = current.strategy;
return next;
});
};
const load = async (showLoading = true) => {
if (loadInFlight.current) return;
loadInFlight.current = true;
if (showLoading) setLoading(true);
setError("");
try {
const res = await api<UpdateInfo>("/api/update/status");
setLiveInfo(res);
mergeInfo(res);
updateInfoCache = res;
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
if (showLoading) setLoading(false);
loadInFlight.current = false;
}
};
@@ -293,7 +319,8 @@ export default function UpdatePage({
return () => window.clearInterval(timer);
}, [restartAt]);
// Live polling effect: only poll when active job exists
// Live polling effect for an active job. Completed responses are refreshed
// once so latest/checkedAt/strategy stay current; other terminal states stay visible.
useEffect(() => {
const shouldPoll = Boolean(
job && (pollableStatuses.has(job.status) || (job.status === "staged" && job.operation === "apply"))
@@ -326,23 +353,53 @@ export default function UpdatePage({
setReloadReady(true);
notify?.("系统升级完成,请刷新页面", "success");
}
if (
if (result.job.status === "completed") {
void load(false);
} else if (
pollableStatuses.has(result.job.status) ||
(result.job.status === "staged" && result.job.operation === "apply")
) {
schedule(1500);
}
} catch {
} catch (caught) {
if (disposed) return;
const status = caught instanceof ApiError ? caught.status : 0;
const message = caught instanceof Error ? caught.message : "读取更新任务状态失败";
if (status === 404) {
setPollError("更新任务已结束,正在刷新状态。");
void load(false);
return;
}
if (status === 401) {
setPollError("登录已失效,请重新登录。");
return;
}
if (status === 403) {
setPollError("没有权限读取更新任务状态。");
return;
}
failures += 1;
disconnected.current = true;
disconnected.current = status === 0 || status === 408;
recoveredNotice.current = false;
setPollError(
`服务正在平滑重启${
restartSeconds !== null ? `,预计 ${restartSeconds} 秒后恢复` : ",页面正在自动探活重试"
}。升级任务仍在后台安全执行。`
);
schedule(Math.min(1500 * 2 ** Math.min(failures, 3), 10_000));
if (status === 409) {
setPollError(`${message},正在刷新状态。`);
void load(false);
return;
}
if (status === 429) {
setPollError(`${message},稍后继续同步。`);
} else if (status === 408) {
setPollError("读取更新任务状态超时,正在重试。");
} else if (status === 0) {
setPollError("更新服务连接异常,正在重试。");
} else {
setPollError(`${message},正在重试。`);
}
const retryAfter = caught instanceof ApiError && caught.retryAfter
? Math.max(1000, caught.retryAfter * 1000)
: Math.min(1500 * 2 ** Math.min(failures, 3), 10_000);
schedule(retryAfter);
}
};
void poll();
@@ -352,6 +409,42 @@ export default function UpdatePage({
};
}, [job?.id, job?.status, job?.operation, notify]);
// With no visible job, make a low-frequency status request so a task created
// elsewhere can still appear without creating a request storm.
useEffect(() => {
if (job) return;
let timer: number | undefined;
let disposed = false;
const discover = () => {
if (disposed || document.visibilityState !== "visible") return;
void load(false);
};
const schedule = () => {
if (disposed) return;
if (timer !== undefined) window.clearTimeout(timer);
timer = window.setTimeout(() => {
discover();
schedule();
}, 5000);
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
discover();
schedule();
} else if (timer !== undefined) {
window.clearTimeout(timer);
timer = undefined;
}
};
if (document.visibilityState === "visible") schedule();
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
disposed = true;
if (timer !== undefined) window.clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [job?.id, job?.status, job?.operation]);
// Check update handler
const check = async () => {
if (checkInFlight.current) return;
@@ -359,7 +452,8 @@ export default function UpdatePage({
setChecking(true);
setError("");
try {
setLiveInfo(await api<UpdateInfo>("/api/update/check", { method: "POST" }));
const result = await api<UpdateInfo>("/api/update/check", { method: "POST" });
mergeInfo(result, true);
notify?.("版本检查完成", "info");
} catch (e) {
if (e instanceof ApiError && e.status === 429) {
@@ -375,7 +469,8 @@ export default function UpdatePage({
// Cancel queued job handler
const cancelJob = async () => {
if (cancelling) return;
if (cancelInFlight.current || !job?.id) return;
cancelInFlight.current = true;
setCancelling(true);
try {
await api("/api/update/cancel", { method: "POST", body: JSON.stringify({ jobId: job?.id }) });
@@ -387,6 +482,7 @@ export default function UpdatePage({
notify?.((e as Error).message, "error");
} finally {
setCancelling(false);
cancelInFlight.current = false;
}
};
@@ -487,7 +583,7 @@ export default function UpdatePage({
if (!job) return 0;
if (job.status === "completed" || job.status === "staged") return 100;
if (job.status === "downloading") {
if (!job.sizeBytes || !job.downloadedBytes) return 10;
if (!job.sizeBytes || !job.downloadedBytes) return 0;
return Math.min(99, Math.max(1, Math.round((job.downloadedBytes / job.sizeBytes) * 100)));
}
if (job.status === "verifying") return 99;