diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 200112b7..f6459876 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -597,9 +597,33 @@ export interface FolderAncestorsResponse { * discriminant is the `outcome` field, not the object key. */ export type JobOutcome = - | { outcome: 'ok'; count: number; extra?: unknown } + | { outcome: 'ok'; count: number; extra?: JobOutcomeExtra } | { outcome: 'err'; message: string }; +/** + * The parts of a job outcome's free-form `extra` the panel reads. + * + * Deliberately narrow — most keys are per-job counters nothing generic + * should switch on. These three describe the RUN's shape rather than + * its work, and the panel has to render them: + * + * A run that stopped because the backend was unreachable reports + * `outcome: 'ok'` — it did not fail, it paused and can be resumed. Read + * alone that renders as a green "ok" pill, which is exactly wrong: a + * paused `backend_migration` still holds `migration_readonly` and is + * refusing writes application-wide. `retryable` is what lets the row + * say so. + */ +export interface JobOutcomeExtra { + /** The run stopped at its cursor and can be resumed. */ + paused?: boolean; + /** It stopped because the ENVIRONMENT failed, not because an + * operator asked — `reason` says what. */ + retryable?: boolean; + reason?: string; + [key: string]: unknown; +} + /** * `JobSummary` — one row per registered job in `GET /api/admin/jobs`. * Cadence + last-run bookkeeping. `interval_ms` / `next_run_at` are diff --git a/frontend/src/lib/components/AdminJobsPanel.svelte b/frontend/src/lib/components/AdminJobsPanel.svelte index 7b0a0770..6360f5fc 100644 --- a/frontend/src/lib/components/AdminJobsPanel.svelte +++ b/frontend/src/lib/components/AdminJobsPanel.svelte @@ -516,8 +516,32 @@ } } + // A run that stopped because the backend was unreachable, rather than + // because an operator asked it to stop. + // + // It reports `outcome: 'ok'` on the wire — correctly, since it did + // not fail and a Resume continues it — but rendering that as a plain + // green "ok" hides the thing worth acting on. A paused + // `backend_migration` is still holding `migration_readonly` and + // refusing writes across the whole app; the row has to say so. + function stoppedOnBackendFailure(job: JobSummary): boolean { + return job.last_outcome?.outcome === 'ok' && job.last_outcome.extra?.retryable === true; + } + + function backendFailureReason(job: JobSummary): string | undefined { + if (job.last_outcome?.outcome !== 'ok') return undefined; + const reason = job.last_outcome.extra?.reason; + return typeof reason === 'string' ? reason : undefined; + } + function outcomeLabel(job: JobSummary): string { if (!job.last_outcome) return t('admin.jobs.never', 'never'); + // Checked before the findings branches: a run that never finished + // has nothing meaningful to say about findings, and "0 issues" on + // an aborted scan is a worse answer than "blocked". + if (stoppedOnBackendFailure(job)) { + return t('admin.jobs.outcome_blocked', 'blocked'); + } if (job.last_outcome.outcome === 'ok') { // `ok` on the wire = dispatch completed. If any actionable // findings surfaced, we flip to "issues" (amber). If only @@ -539,6 +563,14 @@ if (job.last_outcome.outcome !== 'ok') { return 'jobs-panel__pill jobs-panel__pill--err'; } + // Amber, not red: nothing is broken and no data was lost — the + // run is waiting for the backend to come back and a Resume + // continues it. Red would read as "this job is failing" and + // invite a cancel, which for a migration also throws away the + // copy already done. + if (stoppedOnBackendFailure(job)) { + return 'jobs-panel__pill jobs-panel__pill--paused'; + } if (actionableFindingCount(job) > 0) { return 'jobs-panel__pill jobs-panel__pill--paused'; } @@ -912,7 +944,14 @@ {timeAgo(job.last_run_at)}
- {outcomeLabel(job)} + + {outcomeLabel(job)} {#if actionableFindingCount(job) > 0} {@const findings = actionableFindingCount(job)}