feat(msg-bus): wire jobs follow up

This commit is contained in:
Edouard Vanbelle
2026-09-11 19:41:18 +02:00
parent 84ea005b65
commit a6138aa4d9
16 changed files with 766 additions and 82 deletions
@@ -35,6 +35,8 @@
purgeJobRuns
} from '$lib/api/endpoints/adminJobs';
import type { Finding, JobParam, JobSummary, RunSummary, RunStatus } from '$lib/api/types';
import { messageBus } from '$lib/message-bus/client.svelte';
import { serverConfig } from '$lib/stores/serverConfig.svelte';
// ─── State ────────────────────────────────────────────────────────
@@ -222,6 +224,68 @@
return () => stopPolling();
});
// ─── Live updates via the message bus ─────────────────────────────
//
// Subscribes to `job:{name}` for every registered job so a run's
// start/end flips this panel's state within a network hop instead
// of waiting up to POLL_MS for the next poll tick. The 5s polling
// stays as fallback — messages that arrive while the tab was hidden
// (Page Visibility grace-close in `messageBus`) are lost, and
// polling reconciles.
//
// Progress publishes aren't wired yet (deferred — see
// `docs/plan/message-bus.md`), so the handler treats
// `job_run_progress` as a benign no-op and simply refetches the
// row's runs when a run ends. When per-handler progress emits
// land, this composable is where `onProgress` will map into the
// runs table without a poll round-trip.
//
// Keyed on the SORTED name set — the polling refresh reassigns
// `jobs` on every tick with a fresh array, which would tear down
// and rebuild every sub if the effect keyed on `jobs` identity.
// The registered set is fixed at server boot, so this stable key
// stops the effect churning.
const jobNameKey = $derived(
jobs
?.map((j) => j.name)
.sort()
.join('|') ?? ''
);
$effect(() => {
// Don't attempt to open a socket if the server has the bus
// disabled — the WS route is unmounted (404) and the circuit
// breaker would just count failures.
if (!serverConfig.features.message_bus) return;
if (!jobNameKey) return;
const names = jobNameKey.split('|').filter(Boolean);
const releases = names.map((name) =>
messageBus.subscribe(
`job:${name}`,
(params) => {
// `job_run_progress` currently has no publisher —
// treat any incoming variant defensively.
if (params.event === 'job_run_started') {
void loadJobs();
if (expandedJob === name) void loadRuns(name);
} else if (params.event === 'job_run_ended') {
void loadJobs();
if (expandedJob === name) void loadRuns(name);
}
},
() => {
// Server-side eviction — admin role revoked or bus
// disabled mid-session. Nothing surgical to do; the
// next poll will reflect whatever changed and the
// operator's UI will render normally.
}
)
);
return () => {
for (const release of releases) release();
};
});
async function loadRuns(name: string) {
runsLoadingByJob = { ...runsLoadingByJob, [name]: true };
runsErrorByJob = { ...runsErrorByJob, [name]: '' };
@@ -0,0 +1,76 @@
// Admin-job-dashboard sugar around `useTopic`.
//
// Subscribes to `job:{name}` and dispatches the three `rt.event`
// variants — `job_run_started`, `job_run_progress`, `job_run_ended`
// — to per-verb handlers. Admin-only server-side (Class 3, see
// `application/ports/message_bus_ports.rs::required_perm`); a
// non-admin caller sees `topic_forbidden` on subscribe and the
// subscription is dropped.
//
// This composable mirrors `useFolderTopic` but is deliberately
// separate: the two share nothing beyond `useTopic`, and merging
// them would smear two AuthZ classes (ResourceRead vs RoleAdmin)
// into one call surface.
import { useTopic } from './useTopic.svelte';
import type RtEventParams from '$lib/generated/message-bus/RtEventParams';
import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams';
import type JobRunStartedData from '$lib/generated/message-bus/JobRunStartedData';
import type JobRunProgressData from '$lib/generated/message-bus/JobRunProgressData';
import type JobRunEndedData from '$lib/generated/message-bus/JobRunEndedData';
/**
* Optional per-verb handlers for a single job's run stream. Any
* subset is accepted; unhandled verbs fall through silently.
*
* `onEnded` is the canonical "the server is done publishing on this
* topic for now" signal — the admin dashboard uses it to switch a
* row back to "idle" and stop expecting progress updates. The
* subscription itself stays open (jobs can run again), so callers
* that want a one-shot pattern should track that in their own state.
*/
export interface JobTopicHandlers {
onStarted?: (data: JobRunStartedData) => void;
onProgress?: (data: JobRunProgressData) => void;
onEnded?: (data: JobRunEndedData) => void;
/** Server evicted the subscription — admin role revoked, or
* the message bus itself was disabled mid-session. */
onRevoked?: (params: RtRevokedParams) => void;
}
/**
* Subscribe to `job:{name}` and dispatch each `rt.event`
* notification to the matching per-verb handler.
*
* `name` accepts the same shapes as `useTopic`'s `topic` — a plain
* string, a nullable string (null = don't subscribe yet), or a
* getter that reads from reactive state so the subscription follows
* the currently-selected job.
*/
export function useJobTopic(
name: string | null | (() => string | null),
handlers: JobTopicHandlers
): void {
const topic = () => {
const n = typeof name === 'function' ? name() : name;
return n ? `job:${n}` : null;
};
useTopic(topic, (params) => dispatch(params, handlers), handlers.onRevoked);
}
function dispatch(params: RtEventParams, handlers: JobTopicHandlers): void {
// The generated `RtEventKind` string-enum values match the Rust
// `#[serde(rename_all = "snake_case")]` variants exactly — see
// `application/ports/message_bus_ports.rs::MessageBusEvent`.
switch (params.event) {
case 'job_run_started':
handlers.onStarted?.(params.data as JobRunStartedData);
return;
case 'job_run_progress':
handlers.onProgress?.(params.data as JobRunProgressData);
return;
case 'job_run_ended':
handlers.onEnded?.(params.data as JobRunEndedData);
return;
}
}
@@ -0,0 +1,9 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface JobRunEndedData {
ended_at: string;
name: string;
reason?: string | null;
success: boolean;
}
export type { JobRunEndedData as default };
@@ -0,0 +1,9 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface JobRunProgressData {
message?: string | null;
name: string;
step?: number | null;
total?: number | null;
}
export type { JobRunProgressData as default };
@@ -0,0 +1,8 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface JobRunStartedData {
actor: string;
name: string;
started_at: string;
}
export type { JobRunStartedData as default };
@@ -6,6 +6,9 @@ enum RtEventKind {
FOLDER_CREATED = 'folder_created',
FOLDER_RENAMED = 'folder_renamed',
FOLDER_MOVED = 'folder_moved',
FOLDER_DELETED = 'folder_deleted'
FOLDER_DELETED = 'folder_deleted',
JOB_RUN_STARTED = 'job_run_started',
JOB_RUN_PROGRESS = 'job_run_progress',
JOB_RUN_ENDED = 'job_run_ended'
}
export type { RtEventKind as default };
@@ -6,6 +6,9 @@ import type FolderCreatedData from './FolderCreatedData';
import type FolderRenamedData from './FolderRenamedData';
import type FolderMovedData from './FolderMovedData';
import type FolderDeletedData from './FolderDeletedData';
import type JobRunStartedData from './JobRunStartedData';
import type JobRunProgressData from './JobRunProgressData';
import type JobRunEndedData from './JobRunEndedData';
import type RtEventKind from './RtEventKind';
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
@@ -18,7 +21,10 @@ interface RtEventParams {
| FolderCreatedData
| FolderRenamedData
| FolderMovedData
| FolderDeletedData;
| FolderDeletedData
| JobRunStartedData
| JobRunProgressData
| JobRunEndedData;
event: RtEventKind;
topic: string;
}