feat(msg-bus): wire jobs follow up
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user