This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user