From 4b4a1084cbc7c0cab0a61894958901dc61e3a355 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sat, 8 Aug 2026 12:25:05 +0200 Subject: [PATCH] refactor(jobs): group Admin -> Jobs into sections instead of one flat list Nineteen jobs rendered as one alphabetical list in which a pipeline, one of its steps, a standalone cron job and a manual-only job were indistinguishable. Four sections, ordered by the API (category rank, then trading-day order within it) so the client does not re-derive ordering: Pipelines, Pipeline steps, Standalone scheduled, Manual only. An unrecognised category still renders, under "Other" -- a stray section beats a job silently vanishing from the admin page. Sections rather than nesting steps under their parent, which is what the flat "runs via pipeline" label invited. Membership is many-to-many -- data_collector runs in all four pipelines, alerts and outcome_evaluator in two each -- so nesting means duplicating those rows, and the duplicates would each carry a Trigger button despite not being distinct actions: only plain collect_ohlcv is registered, while the near-close and after-close variants are different coroutines that are not individually triggerable. Instead each pipeline card lists its step sequence and each step says which pipelines run it, which is the same information without a button that lies. Every job now answers "when does this next run" the same way: its own timer, its soonest enabled parent's ("Next via Intraday Pipeline in 42m"), or "manual only". Jobs with no recorded run say so explicitly rather than showing nothing. The status chip and the rate-limit banner still read runtime_* only, so a persisted failure cannot pin either to a stale state. Co-Authored-By: Claude Opus 5 --- frontend/src/components/admin/JobControls.tsx | 387 +++++++++++------- 1 file changed, 250 insertions(+), 137 deletions(-) diff --git a/frontend/src/components/admin/JobControls.tsx b/frontend/src/components/admin/JobControls.tsx index 3bbe4b8..95e98e9 100644 --- a/frontend/src/components/admin/JobControls.tsx +++ b/frontend/src/components/admin/JobControls.tsx @@ -1,5 +1,5 @@ import { useJobs, useToggleJob, useTriggerJob } from '../../hooks/useAdmin'; -import type { JobStatus } from '../../api/admin'; +import type { JobCategory, JobStatus } from '../../api/admin'; import { SkeletonTable } from '../ui/Skeleton'; function formatNextRun(iso: string | null): string { @@ -11,7 +11,8 @@ function formatNextRun(iso: string | null): string { const mins = Math.round(diffMs / 60_000); if (mins < 60) return `in ${mins}m`; const hrs = Math.round(mins / 60); - return `in ${hrs}h`; + if (hrs < 48) return `in ${hrs}h`; + return `in ${Math.round(hrs / 24)}d`; } function formatAgo(iso: string | null | undefined): string { @@ -30,6 +31,28 @@ function lastRunColor(status: string | null | undefined): string { return 'text-gray-500'; } +/** The four kinds of job, in the order the API already sorts them. A job whose + * category the client does not recognise still renders, under "Other" — better + * a stray section than a job that silently vanishes from the admin page. */ +const SECTIONS: { key: JobCategory; title: string; hint: string }[] = [ + { + key: 'pipeline', + title: 'Pipelines', + hint: 'own schedule · run their steps in order', + }, + { + key: 'pipeline_step', + title: 'Pipeline steps', + hint: 'no timer of their own · still triggerable individually', + }, + { + key: 'scheduled', + title: 'Standalone scheduled', + hint: 'own schedule · independent of any pipeline', + }, + { key: 'manual', title: 'Manual only', hint: 'never fires on its own' }, +]; + /** One consistent answer per job: its own timer, its parent's, or "manual only". * A step has no schedule of its own, so reporting one was the original bug. */ function NextRun({ job, labels }: { job: JobStatus; labels: Record }) { @@ -52,21 +75,222 @@ function NextRun({ job, labels }: { job: JobStatus; labels: RecordNext run {formatNextRun(job.next_run_at)}; } +/** Membership, shown rather than nested: a step can belong to several pipelines + * (data_collector is in all four), so duplicating rows under each parent would + * render Trigger buttons that are not distinct actions. */ +function Membership({ job, labels }: { job: JobStatus; labels: Record }) { + const name = (id: string) => labels[id] ?? id; + if (job.category === 'pipeline' && job.steps?.length) { + return ( +
+ {job.steps.map(name).join(' → ')} +
+ ); + } + if (job.category === 'pipeline_step' && job.pipelines?.length) { + return ( +
+ runs in: {job.pipelines.map(name).join(', ')} +
+ ); + } + return null; +} + +interface JobCardProps { + job: JobStatus; + labels: Record; + anyJobRunning: boolean; + runningJobLabel?: string; + onToggle: (job: JobStatus) => void; + onTrigger: (job: JobStatus) => void; + togglePending: boolean; + triggerPending: boolean; +} + +function JobCard({ + job, + labels, + anyJobRunning, + runningJobLabel, + onToggle, + onTrigger, + togglePending, + triggerPending, +}: JobCardProps) { + return ( +
+
+
+ {/* Status dot */} + +
+ {job.label} +
+ {/* Live state only — a persisted error must not read as the + current status forever, so this never consults last_run_*. */} + + {job.running + ? 'Running' + : job.runtime_status === 'rate_limited' + ? 'Paused (rate-limited)' + : job.runtime_status === 'deferred' + ? 'Deferred (retrying)' + : job.runtime_status === 'error' + ? 'Last run error' + : job.enabled + ? 'Active' + : 'Inactive'} + + {job.enabled && } + {!job.registered && ( + Not registered + )} +
+ + {/* Persisted, so this survives a deploy — unlike runtime_* above. */} + {!job.running && job.last_run_at && ( +
+ Last run {formatAgo(job.last_run_at)} + {job.last_run_status ? ` · ${job.last_run_status}` : ''} + {job.last_run_message ? ` — ${job.last_run_message}` : ''} +
+ )} + {!job.running && !job.last_run_at && ( +
No run recorded yet
+ )} + {job.running && ( +
+
+ + {job.runtime_processed ?? 0} + {typeof job.runtime_total === 'number' ? ` / ${job.runtime_total}` : ''} + {' '}processed + + {typeof job.runtime_progress_pct === 'number' && ( + {Math.max(0, Math.min(100, job.runtime_progress_pct)).toFixed(0)}% + )} +
+
+
+
+ {job.runtime_current_ticker && ( +
Current: {job.runtime_current_ticker}
+ )} +
+ )} +
+
+ +
+ + +
+
+ {anyJobRunning && !job.running && ( +
+ Manual trigger blocked while {runningJobLabel ?? 'another job'} is running. +
+ )} +
+ ); +} + export function JobControls() { const { data: jobs, isLoading } = useJobs(); const toggleJob = useToggleJob(); const triggerJob = useTriggerJob(); + const all = jobs ?? []; // Job id -> display label, so a step can name its parent pipeline. - const labels = Object.fromEntries((jobs ?? []).map((job) => [job.name, job.label])); - const anyJobRunning = (jobs ?? []).some((job) => job.running); - const runningJob = jobs?.find((job) => job.running); - const pausedJob = jobs?.find((job) => !job.running && job.runtime_status === 'rate_limited'); - const runningJobLabel = runningJob?.label; + const labels = Object.fromEntries(all.map((job) => [job.name, job.label])); + const anyJobRunning = all.some((job) => job.running); + const runningJob = all.find((job) => job.running); + const pausedJob = all.find((job) => !job.running && job.runtime_status === 'rate_limited'); if (isLoading) return ; + const known = new Set(SECTIONS.map((s) => s.key)); + const groups: { key: string; title: string; hint: string; jobs: JobStatus[] }[] = [ + ...SECTIONS.map((section) => ({ + ...section, + jobs: all.filter((job) => job.category === section.key), + })), + { + key: 'other', + title: 'Other', + hint: 'uncategorised', + jobs: all.filter((job) => !job.category || !known.has(job.category)), + }, + ]; + + const cardProps = { + labels, + anyJobRunning, + runningJobLabel: runningJob?.label, + onToggle: (job: JobStatus) => + toggleJob.mutate({ jobName: job.name, enabled: !job.enabled }), + onTrigger: (job: JobStatus) => triggerJob.mutate(job.name), + togglePending: toggleJob.isPending, + triggerPending: triggerJob.isPending, + }; + return ( -
+
{runningJob && (
@@ -85,7 +309,7 @@ export function JobControls() { : ''}
-
+
)} {runningJob.runtime_message && ( -
- {runningJob.runtime_message} -
+
{runningJob.runtime_message}
)}
)} @@ -131,132 +353,23 @@ export function JobControls() {
)} - {jobs?.map((job) => ( -
-
-
- {/* Status dot */} - -
- {job.label} -
- - {job.running - ? 'Running' - : job.runtime_status === 'rate_limited' - ? 'Paused (rate-limited)' - : job.runtime_status === 'deferred' - ? 'Deferred (retrying)' - : job.runtime_status === 'error' - ? 'Last run error' - : job.enabled - ? 'Active' - : 'Inactive'} - - {job.enabled && } - {!job.registered && ( - Not registered - )} -
- {/* Persisted, so this survives a deploy — unlike runtime_*, - which the status chip above still reads for live state. */} - {!job.running && job.last_run_at && ( -
- Last run {formatAgo(job.last_run_at)} - {job.last_run_status ? ` · ${job.last_run_status}` : ''} - {job.last_run_message ? ` — ${job.last_run_message}` : ''} -
- )} - {job.running && ( -
-
- - {job.runtime_processed ?? 0} - {typeof job.runtime_total === 'number' ? ` / ${job.runtime_total}` : ''} - {' '}processed - - {typeof job.runtime_progress_pct === 'number' && ( - {Math.max(0, Math.min(100, job.runtime_progress_pct)).toFixed(0)}% - )} -
-
-
-
- {job.runtime_current_ticker && ( -
Current: {job.runtime_current_ticker}
- )} -
- )} -
-
- -
- - -
-
- {anyJobRunning && !job.running && ( -
- Manual trigger blocked while {runningJobLabel ?? 'another job'} is running. -
- )} -
- ))} + + {group.jobs.map((job) => ( + + ))} + + ), + )}
); }