From 4336eca4d1fe17b87d4fd94b8884f621eef6e652 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 29 Jul 2026 07:57:34 +0200 Subject: [PATCH] feat(recoverable-job): add admin page --- frontend/src/lib/api/endpoints/adminJobs.ts | 134 +++ frontend/src/lib/api/types.ts | 74 ++ .../src/lib/components/AdminJobsPanel.svelte | 944 ++++++++++++++++++ frontend/src/routes/admin/+page.svelte | 26 +- frontend/src/routes/search/+page.svelte | 2 +- frontend/static/locales/en.json | 51 +- .../services/consistency_batch_service.rs | 4 +- src/interfaces/api/handlers/admin_handler.rs | 5 +- 8 files changed, 1232 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/api/endpoints/adminJobs.ts create mode 100644 frontend/src/lib/components/AdminJobsPanel.svelte diff --git a/frontend/src/lib/api/endpoints/adminJobs.ts b/frontend/src/lib/api/endpoints/adminJobs.ts new file mode 100644 index 00000000..7e61ce0f --- /dev/null +++ b/frontend/src/lib/api/endpoints/adminJobs.ts @@ -0,0 +1,134 @@ +/** + * Admin JobRegistry endpoints — `/api/admin/jobs*` (see + * `docs/plan/job-registry.md`). Powers the "Jobs" tab of the admin panel. + * + * Every mutation goes through the standard admin auth path (Bearer JWT + * + admin-middleware role check). Read endpoints are cheap enough to + * poll while the panel is open. + */ +import { apiFetch, apiJson } from '$lib/api/client'; +import { getCsrfHeaders } from '$lib/api/csrf'; +import type { Finding, JobOutcome, JobSummary, RunSummary } from '$lib/api/types'; + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +/** + * Envelope wrapping the outcome from `POST /api/admin/jobs/{name}/trigger`. + * `ok: true` means "dispatch reached the handler"; the handler's own + * pass/fail is in `outcome.outcome`. For `consistency_batch`, per-child + * outcomes are inside `outcome.extra.per_check`. + */ +export interface TriggerResponse { + ok: boolean; + outcome: JobOutcome; +} + +/** Envelope from `POST /api/admin/jobs/{name}/cancel`. `run_id` is + * the id of the run whose `Running` status was flipped to + * `CancelRequested` (null when nothing was in flight to cancel). */ +export interface CancelResponse { + ok: boolean; + run_id: string | null; +} + +/** + * `GET /api/admin/jobs` — full registry snapshot. One row per registered + * job (periodic + recoverable + coordinators like `consistency_batch`, + * which register as plain JobHandlers). + */ +export function listJobs(): Promise { + return apiJson('/api/admin/jobs', { credentials: 'same-origin' }); +} + +/** + * `POST /api/admin/jobs/{name}/trigger?force=X&deep=X` — dispatch a job + * on-demand. `force` bypasses per-tenant idempotency checks (e.g. + * `trash_cleanup` skipping when nothing is due). `deep` opts into slow + * variants (currently only `storage_consistency`, propagated by + * `consistency_batch` to every child). + * + * Throws on 4xx / 5xx with the backend's error message when present. + * A 404 means the job name isn't registered — surface that specifically + * so callers can distinguish "typo" from "handler blew up". + */ +export async function triggerJob( + name: string, + opts: { force?: boolean; deep?: boolean } = {} +): Promise { + const params = new URLSearchParams(); + if (opts.force) params.set('force', 'true'); + if (opts.deep) params.set('deep', 'true'); + const q = params.toString(); + const url = `/api/admin/jobs/${encodeURIComponent(name)}/trigger${q ? `?${q}` : ''}`; + const res = await apiFetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() } + }); + if (!res.ok) { + let msg = `trigger failed: ${res.status}`; + try { + const body = (await res.json()) as { error?: string; message?: string }; + msg = body.error ?? body.message ?? msg; + } catch { + /* no JSON body */ + } + throw new Error(msg); + } + return (await res.json()) as TriggerResponse; +} + +/** + * `POST /api/admin/jobs/{name}/cancel` — cooperatively request cancel + * of the currently running instance. The handler observes it on its + * next `store.status()` poll and returns `RunOutcome::Paused` at the + * next safe boundary. If nothing is running, this is a no-op that + * returns `run_id: null`. + */ +export async function cancelJob(name: string): Promise { + const res = await apiFetch(`/api/admin/jobs/${encodeURIComponent(name)}/cancel`, { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() } + }); + if (!res.ok) { + let msg = `cancel failed: ${res.status}`; + try { + const body = (await res.json()) as { error?: string; message?: string }; + msg = body.error ?? body.message ?? msg; + } catch { + /* no JSON body */ + } + throw new Error(msg); + } + return (await res.json()) as CancelResponse; +} + +/** + * `GET /api/admin/jobs/{name}/runs?limit=N` — history of recoverable + * runs for `name`, newest first. Backend caps `limit` at 100. + */ +export function listRuns(name: string, limit = 20): Promise { + return apiJson(`/api/admin/jobs/${encodeURIComponent(name)}/runs?limit=${limit}`, { + credentials: 'same-origin' + }); +} + +/** + * `GET /api/admin/jobs/{name}/runs/{id}/findings?limit=N&offset=M` — + * paginated findings for a specific run. Empty list = clean run, + * 404 = unknown run id. + */ +export function listFindings( + name: string, + runId: string, + opts: { limit?: number; offset?: number } = {} +): Promise { + const params = new URLSearchParams(); + params.set('limit', String(opts.limit ?? 100)); + if (opts.offset) params.set('offset', String(opts.offset)); + return apiJson( + `/api/admin/jobs/${encodeURIComponent(name)}/runs/${encodeURIComponent(runId)}/findings?${params}`, + { credentials: 'same-origin' } + ); +} diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 38da7e5b..dd3a7fb9 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -522,3 +522,77 @@ export interface FolderAncestorsResponse { ancestors: FolderAncestor[]; access_source: AccessSource; } + +// ─── Job registry (Part 1 + Part 2) ──────────────────────────────────────── +// +// Maps `src/infrastructure/scheduler/*` DTOs 1:1. See +// `docs/plan/job-registry.md` for the backend contract; the shapes below +// are what the `/api/admin/jobs*` endpoints emit. + +/** + * `JobOutcome` — the uniform outcome the scheduler logs and stores for + * every job dispatch. Serialised with `#[serde(tag = "outcome")]` so the + * discriminant is the `outcome` field, not the object key. + */ +export type JobOutcome = + | { outcome: 'ok'; count: number; extra?: unknown } + | { outcome: 'err'; message: string }; + +/** + * `JobSummary` — one row per registered job in `GET /api/admin/jobs`. + * Cadence + last-run bookkeeping. `interval_ms` / `next_run_at` are + * `undefined` on on-demand jobs (serde skips `Option::None`). + */ +export interface JobSummary { + name: string; + interval_ms?: number; + next_run_at?: string; + last_run_at?: string; + last_outcome?: JobOutcome; + running: boolean; +} + +/** + * `RunStatus` values allowed in `jobs.recoverable_runs.status`. The + * non-terminal set (Running / Paused / CancelRequested) is what the + * DB's `one_active_run_per_job` partial unique index scopes. + */ +export type RunStatus = 'Running' | 'Paused' | 'CancelRequested' | 'Completed' | 'Failed'; + +/** + * `RunSummary` — one row per recoverable-job run from + * `GET /api/admin/jobs/{name}/runs`. Terminal + non-terminal rows both + * appear. `stats` / `params` are opaque JSON — job-specific shape; + * consumers should key off `job_name` to decide what to render. + * `cursor_hex` is present only when the run has advanced past the + * initial state (paused mid-scan is the typical case). + */ +export interface RunSummary { + id: string; + job_name: string; + status: RunStatus; + started_at: string; + last_progress_at: string; + completed_at?: string; + stats: Record; + params: Record; + cursor_hex?: string; + error_message?: string; +} + +/** + * `Finding` — one row from `GET /api/admin/jobs/{name}/runs/{id}/findings`. + * Persisted by consistency tenants via `store.record_finding()`. Consumers + * key off `kind` to know the shape of `detail` (per-tenant JSON — e.g. + * `stale_used_bytes` carries `{cached, actual, delta}`; `missing_blob` + * carries `{blob_hash}`; …). + */ +export interface Finding { + id: string; + run_id: string; + kind: string; + severity: string; + resource_id?: string; + detail: Record; + created_at: string; +} diff --git a/frontend/src/lib/components/AdminJobsPanel.svelte b/frontend/src/lib/components/AdminJobsPanel.svelte new file mode 100644 index 00000000..1f089a16 --- /dev/null +++ b/frontend/src/lib/components/AdminJobsPanel.svelte @@ -0,0 +1,944 @@ + + + +
+
+
+

{t('admin.jobs.title', 'Jobs')}

+

+ {t( + 'admin.jobs.hint', + 'Fires periodic + on-demand jobs. Consistency checks are safe to run at any time — they are read-only.' + )} +

+
+ {#if hasBatch} +
+ + +
+ {/if} +
+ + {#if loadError} +

{loadError}

+ {:else if !jobs} +

{t('common.loading', 'Loading…')}

+ {:else if jobs.length === 0} +

{t('admin.jobs.none_registered', 'No jobs registered.')}

+ {:else} + + + + + + + + + + + + + {#each jobs as job (job.name)} + {@const runs = runsByJob[job.name]} + {@const runsErr = runsErrorByJob[job.name]} + {@const runsLoading = runsLoadingByJob[job.name]} + {@const expandedRun = expandedRunByJob[job.name] ?? null} + {@const canExpand = isRecoverable(job)} + + + + + + + + + + {#if expandedJob === job.name} + + + + {/if} + {/each} + +
{t('admin.jobs.col_name', 'Name')}{t('admin.jobs.col_cadence', 'Cadence')}{t('admin.jobs.col_last_run', 'Last run')}{t('admin.jobs.col_outcome', 'Outcome')}{t('admin.jobs.col_state', 'State')}{t('admin.jobs.col_actions', 'Actions')}
+ {#if canExpand} + + {:else} + {job.name} + {/if} + {cadenceLabel(job)}{timeAgo(job.last_run_at)}{outcomeLabel(job)} + {#if isRunning(job)} + + {t('admin.jobs.state_running', 'running')} + + {:else} + — + {/if} + + + {#if supportsDeep(job.name)} + + {/if} + {#if isRunning(job) && canExpand} + + {/if} +
+
+
+

{t('admin.jobs.runs_title', 'Recent runs')}

+ +
+ {#if runsErr} +

{runsErr}

+ {:else if !runs} +

{t('common.loading', 'Loading…')}

+ {:else if runs.length === 0} +

+ {t('admin.jobs.no_runs', 'No runs yet.')} +

+ {:else} + + + + + + + + + + + + + + {#each runs as run (run.id)} + {@const scanned = statNumber(run, 'scanned_count')} + {@const findingCount = statNumber(run, 'finding_count')} + {@const isRunExpanded = expandedRun === run.id} + + + + + + + + + + {#if isRunExpanded} + {@const findings = findingsByRun[run.id]} + {@const findingsErr = findingsErrorByRun[run.id]} + {@const fLoading = findingsLoadingByRun[run.id]} + + + + {/if} + {/each} + +
{t('admin.jobs.col_started_at', 'Started')}{t('admin.jobs.col_status', 'Status')}{t('admin.jobs.col_duration', 'Duration')}{t('admin.jobs.col_scanned', 'Scanned')}{t('admin.jobs.col_findings', 'Findings')} + {t('admin.jobs.col_error', 'Error')} +
+ + + {timeAgo(run.started_at)} + + + {run.status} + + + {runDurationLabel(run)} + + {scanned ?? '—'} + + {findingCount ?? 0} + + {#if run.error_message} + {run.error_message} + {:else} + — + {/if} +
+
+
+ + {t('admin.jobs.run_json', 'Run summary (JSON)')} + +
{JSON.stringify(
+																				{
+																					id: run.id,
+																					status: run.status,
+																					started_at: run.started_at,
+																					last_progress_at: run.last_progress_at,
+																					completed_at: run.completed_at,
+																					stats: run.stats,
+																					params: run.params,
+																					cursor_hex: run.cursor_hex,
+																					error_message: run.error_message
+																				},
+																				null,
+																				2
+																			)}
+
+
+
+

+ {t('admin.jobs.findings_title', 'Findings')} +

+ +
+ {#if findingsErr} +

+ {findingsErr} +

+ {:else if !findings} +

+ {t('common.loading', 'Loading…')} +

+ {:else if findings.length === 0} +

+ {t('admin.jobs.no_findings', 'No findings — clean run.')} +

+ {:else} + + + + + + + + + + + {#each findings as f (f.id)} + + + + + + + {/each} + +
+ {t('admin.jobs.col_kind', 'Kind')} + + {t('admin.jobs.col_severity', 'Severity')} + + {t('admin.jobs.col_resource', 'Resource')} + + {t('admin.jobs.col_detail', 'Detail')} +
{f.kind} + + {f.severity} + + + {f.resource_id ?? '—'} + + {JSON.stringify(f.detail)} +
+ {/if} +
+
+
+ {/if} +
+
+ {/if} +
+ + diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 045046bf..82bd8d57 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -76,6 +76,7 @@ DrivePoliciesPartial, User } from '$lib/api/types'; + import AdminJobsPanel from '$lib/components/AdminJobsPanel.svelte'; import Icon from '$lib/icons/Icon.svelte'; import Modal from '$lib/components/Modal.svelte'; import OwnerAvatarStack from '$lib/components/OwnerAvatarStack.svelte'; @@ -172,7 +173,16 @@ } } - type Tab = 'dashboard' | 'users' | 'drives' | 'mounts' | 'plugins' | 'oidc' | 'storage' | 'smtp'; + type Tab = + | 'dashboard' + | 'users' + | 'drives' + | 'mounts' + | 'plugins' + | 'oidc' + | 'storage' + | 'smtp' + | 'jobs'; let tab = $state('dashboard'); // Dashboard @@ -1462,7 +1472,8 @@ plugins: false, oidc: false, storage: false, - smtp: false + smtp: false, + jobs: false }); $effect(() => { @@ -1579,6 +1590,15 @@ {t('admin.plugins', 'Plugins')} + {#if tab === 'dashboard'} @@ -2787,6 +2807,8 @@ {/if} + {:else if tab === 'jobs'} + {:else if !pluginsAvailable}

{t('admin.plugins_disabled', 'The plugin subsystem is disabled.')}

{:else if pluginsError} diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index 26c398fa..a1c0a4cf 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -806,7 +806,7 @@