fix: hide stale update failures on status load

This commit is contained in:
Qiufeng
2026-09-04 01:08:46 +08:00
parent a61860fcb3
commit 704740182a
3 changed files with 35 additions and 4 deletions
+9 -2
View File
@@ -930,6 +930,11 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
reply.header("Cache-Control", "no-store");
reconcileOrphanedUpdateJobs(database.sqlite, config);
const cached = publicCheckFromCache(database.sqlite, config);
// Status is a live control surface, not an update history endpoint.
// Terminal failures/cancellations from a previous attempt must not be
// replayed as if the operator had just started an update. They remain in
// the database/audit log, while this endpoint exposes only an actionable
// task (or the latest successful completion for confirmation).
const row = database.sqlite.prepare(`
SELECT id, operation, status, version, platform, asset_name AS assetName,
size_bytes AS sizeBytes, error_message AS errorMessage,
@@ -937,8 +942,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
download_speed_bps AS downloadSpeedBps,
requested_at AS applyQueuedAt
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
FROM update_jobs
WHERE admin_id=? AND status IN (${[...ACTIVE_UPDATE_STATUSES, "completed"].map(() => "?").join(",")})
ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id, ...ACTIVE_UPDATE_STATUSES, "completed") as Record<string, unknown> | undefined;
return {
...cached,
strategy: config.updateStrategy,
+18
View File
@@ -137,6 +137,24 @@ describe("更新 API", () => {
expect(disabledConfig.updateStrategy).toBe("disabled");
});
it("首次进入状态页不会展示历史失败任务,也不会阻断新的检查", async () => {
const session = await login("update-history");
const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-history") as { id: string };
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO update_jobs(id, admin_id, operation, status, version, platform, asset_url, error_message, created_at, updated_at)
VALUES (?, ?, 'download', 'failed', '1.1.0', ?, 'https://updates.example/old.tar.gz', 'old failure', ?, ?)
`).run(randomUUID(), admin.id, detectPlatform().target, now - 60_000, now - 60_000);
const initial = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
expect(initial.statusCode).toBe(200);
expect(initial.json().job).toBeNull();
mockRelease();
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
expect(checked.statusCode).toBe(200);
expect(checked.json().latest).toMatchObject({ version: "1.2.0", isNewer: true });
});
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
const owner = await login("update-owner");
const other = await login("update-other");
+8 -2
View File
@@ -338,7 +338,10 @@ export default function UpdatePage({
else setLoading(false);
}, [isMock]);
const job = info?.job;
// The status endpoint intentionally hides terminal failures/cancellations.
// Keep this guard in the UI as well so a stale response from an older
// server cannot turn a fresh page visit into a false failure state.
const job = info?.job && info.job.status !== "failed" && info.job.status !== "cancelled" ? info.job : null;
useEffect(() => {
if (job && activeStatuses.has(job.status)) setShowPipelineDialog(true);
}, [job?.id, job?.status]);
@@ -441,7 +444,10 @@ export default function UpdatePage({
method: "POST",
body: "{}",
});
setLiveInfo((current) => ({ ...result, job: result.job ?? current?.job ?? null }));
setLiveInfo((current) => ({
...result,
job: result.job ?? (current?.job && activeStatuses.has(current.job.status) ? current.job : null),
}));
notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
} catch (caught) {
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") {