From 704740182aebe1a8e0c15d53d5d6253f3a0b6c8d Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Fri, 4 Sep 2026 01:08:46 +0800 Subject: [PATCH] fix: hide stale update failures on status load --- server/app.ts | 11 +++++++++-- tests/update-api.test.ts | 18 ++++++++++++++++++ web-next/src/pages/update/UpdatePage.tsx | 10 ++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/server/app.ts b/server/app.ts index b91c3f5..5a1b78c 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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 | 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 | undefined; return { ...cached, strategy: config.updateStrategy, diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index e09a70e..f630898 100644 --- a/tests/update-api.test.ts +++ b/tests/update-api.test.ts @@ -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"); diff --git a/web-next/src/pages/update/UpdatePage.tsx b/web-next/src/pages/update/UpdatePage.tsx index 64c836f..9c6e6ac 100644 --- a/web-next/src/pages/update/UpdatePage.tsx +++ b/web-next/src/pages/update/UpdatePage.tsx @@ -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") {