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) => ( + + ))} + + ), + )}
); }