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 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 12:25:05 +02:00
co-authored by Claude Opus 5
parent 22bee28ac7
commit 4b4a1084cb
+201 -88
View File
@@ -1,5 +1,5 @@
import { useJobs, useToggleJob, useTriggerJob } from '../../hooks/useAdmin'; 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'; import { SkeletonTable } from '../ui/Skeleton';
function formatNextRun(iso: string | null): string { function formatNextRun(iso: string | null): string {
@@ -11,7 +11,8 @@ function formatNextRun(iso: string | null): string {
const mins = Math.round(diffMs / 60_000); const mins = Math.round(diffMs / 60_000);
if (mins < 60) return `in ${mins}m`; if (mins < 60) return `in ${mins}m`;
const hrs = Math.round(mins / 60); 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 { function formatAgo(iso: string | null | undefined): string {
@@ -30,6 +31,28 @@ function lastRunColor(status: string | null | undefined): string {
return 'text-gray-500'; 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". /** 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. */ * A step has no schedule of its own, so reporting one was the original bug. */
function NextRun({ job, labels }: { job: JobStatus; labels: Record<string, string> }) { function NextRun({ job, labels }: { job: JobStatus; labels: Record<string, string> }) {
@@ -52,87 +75,51 @@ function NextRun({ job, labels }: { job: JobStatus; labels: Record<string, strin
return <span className={muted}>Next run {formatNextRun(job.next_run_at)}</span>; return <span className={muted}>Next run {formatNextRun(job.next_run_at)}</span>;
} }
export function JobControls() { /** Membership, shown rather than nested: a step can belong to several pipelines
const { data: jobs, isLoading } = useJobs(); * (data_collector is in all four), so duplicating rows under each parent would
const toggleJob = useToggleJob(); * render Trigger buttons that are not distinct actions. */
const triggerJob = useTriggerJob(); function Membership({ job, labels }: { job: JobStatus; labels: Record<string, string> }) {
// Job id -> display label, so a step can name its parent pipeline. const name = (id: string) => labels[id] ?? id;
const labels = Object.fromEntries((jobs ?? []).map((job) => [job.name, job.label])); if (job.category === 'pipeline' && job.steps?.length) {
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;
if (isLoading) return <SkeletonTable rows={4} cols={3} />;
return ( return (
<div className="space-y-3"> <div className="mt-1 text-[11px] leading-relaxed text-gray-600">
{runningJob && ( {job.steps.map(name).join(' → ')}
<div className="rounded-xl border border-blue-400/30 bg-blue-500/10 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-blue-300">
Active job: {runningJob.label}
</div> </div>
<div className="mt-0.5 text-[11px] text-blue-100/80"> );
Manual triggers are blocked until this run finishes. }
if (job.category === 'pipeline_step' && job.pipelines?.length) {
return (
<div className="mt-1 text-[11px] leading-relaxed text-gray-600">
runs in: {job.pipelines.map(name).join(', ')}
</div> </div>
</div> );
<div className="text-[11px] text-blue-200"> }
{runningJob.runtime_processed ?? 0} return null;
{typeof runningJob.runtime_total === 'number' }
? ` / ${runningJob.runtime_total}`
: ''}
</div>
</div>
<div className="mt-2 h-1.5 w-full rounded-full bg-slate-700/80 overflow-hidden">
<div
className="h-full bg-blue-400 transition-all duration-500"
style={{
width: `${
typeof runningJob.runtime_progress_pct === 'number'
? Math.max(5, Math.min(100, runningJob.runtime_progress_pct))
: 30
}%`,
}}
/>
</div>
{runningJob.runtime_current_ticker && (
<div className="mt-1 text-[11px] text-blue-100/80">
Current: {runningJob.runtime_current_ticker}
</div>
)}
{runningJob.runtime_message && (
<div className="mt-1 text-[11px] text-blue-100/80">
{runningJob.runtime_message}
</div>
)}
</div>
)}
{!runningJob && pausedJob && ( interface JobCardProps {
<div className="rounded-xl border border-amber-400/30 bg-amber-500/10 px-4 py-3"> job: JobStatus;
<div className="flex flex-wrap items-center justify-between gap-3"> labels: Record<string, string>;
<div> anyJobRunning: boolean;
<div className="text-xs font-semibold text-amber-300"> runningJobLabel?: string;
Last run paused: {pausedJob.label} onToggle: (job: JobStatus) => void;
</div> onTrigger: (job: JobStatus) => void;
<div className="mt-0.5 text-[11px] text-amber-100/90"> togglePending: boolean;
{pausedJob.runtime_message || 'Rate limit hit. The collector stopped early and will resume from last progress on the next run.'} triggerPending: boolean;
</div> }
</div>
<div className="text-[11px] text-amber-200">
{pausedJob.runtime_processed ?? 0}
{typeof pausedJob.runtime_total === 'number'
? ` / ${pausedJob.runtime_total}`
: ''}
</div>
</div>
</div>
)}
{jobs?.map((job) => ( function JobCard({
<div key={job.name} className="glass p-4 glass-hover"> job,
labels,
anyJobRunning,
runningJobLabel,
onToggle,
onTrigger,
togglePending,
triggerPending,
}: JobCardProps) {
return (
<div className="glass p-4 glass-hover">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Status dot */} {/* Status dot */}
@@ -147,7 +134,9 @@ export function JobControls() {
/> />
<div> <div>
<span className="text-sm font-medium text-gray-200">{job.label}</span> <span className="text-sm font-medium text-gray-200">{job.label}</span>
<div className="flex items-center gap-3 mt-0.5"> <div className="mt-0.5 flex flex-wrap items-center gap-3">
{/* Live state only — a persisted error must not read as the
current status forever, so this never consults last_run_*. */}
<span <span
className={`text-[11px] font-medium ${ className={`text-[11px] font-medium ${
job.running job.running
@@ -178,8 +167,8 @@ export function JobControls() {
<span className="text-[11px] text-red-400">Not registered</span> <span className="text-[11px] text-red-400">Not registered</span>
)} )}
</div> </div>
{/* Persisted, so this survives a deploy — unlike runtime_*, <Membership job={job} labels={labels} />
which the status chip above still reads for live state. */} {/* Persisted, so this survives a deploy — unlike runtime_* above. */}
{!job.running && job.last_run_at && ( {!job.running && job.last_run_at && (
<div className={`mt-1 text-[11px] ${lastRunColor(job.last_run_status)}`}> <div className={`mt-1 text-[11px] ${lastRunColor(job.last_run_status)}`}>
Last run {formatAgo(job.last_run_at)} Last run {formatAgo(job.last_run_at)}
@@ -187,6 +176,9 @@ export function JobControls() {
{job.last_run_message ? `${job.last_run_message}` : ''} {job.last_run_message ? `${job.last_run_message}` : ''}
</div> </div>
)} )}
{!job.running && !job.last_run_at && (
<div className="mt-1 text-[11px] text-gray-600">No run recorded yet</div>
)}
{job.running && ( {job.running && (
<div className="mt-2 space-y-1.5"> <div className="mt-2 space-y-1.5">
<div className="flex items-center justify-between text-[11px] text-gray-400"> <div className="flex items-center justify-between text-[11px] text-gray-400">
@@ -199,7 +191,7 @@ export function JobControls() {
<span>{Math.max(0, Math.min(100, job.runtime_progress_pct)).toFixed(0)}%</span> <span>{Math.max(0, Math.min(100, job.runtime_progress_pct)).toFixed(0)}%</span>
)} )}
</div> </div>
<div className="h-1.5 w-56 rounded-full bg-slate-700/80 overflow-hidden"> <div className="h-1.5 w-56 overflow-hidden rounded-full bg-slate-700/80">
<div <div
className="h-full bg-blue-400 transition-all duration-500" className="h-full bg-blue-400 transition-all duration-500"
style={{ style={{
@@ -222,8 +214,8 @@ export function JobControls() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => toggleJob.mutate({ jobName: job.name, enabled: !job.enabled })} onClick={() => onToggle(job)}
disabled={toggleJob.isPending} disabled={togglePending}
className={`rounded-lg border px-3 py-1.5 text-xs transition-all duration-200 disabled:opacity-50 ${ className={`rounded-lg border px-3 py-1.5 text-xs transition-all duration-200 disabled:opacity-50 ${
job.enabled job.enabled
? 'border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20' ? 'border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20'
@@ -234,14 +226,14 @@ export function JobControls() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => triggerJob.mutate(job.name)} onClick={() => onTrigger(job)}
disabled={triggerJob.isPending || !job.enabled || anyJobRunning} disabled={triggerPending || !job.enabled || anyJobRunning}
className="btn-primary px-3 py-1.5 text-xs disabled:opacity-50 disabled:cursor-not-allowed" className="btn-primary px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-50"
> >
<span> <span>
{job.running {job.running
? 'Running…' ? 'Running…'
: triggerJob.isPending : triggerPending
? 'Triggering…' ? 'Triggering…'
: anyJobRunning : anyJobRunning
? 'Blocked' ? 'Blocked'
@@ -256,7 +248,128 @@ export function JobControls() {
</div> </div>
)} )}
</div> </div>
);
}
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(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 <SkeletonTable rows={4} cols={3} />;
const known = new Set<string>(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 (
<div className="space-y-6">
{runningJob && (
<div className="rounded-xl border border-blue-400/30 bg-blue-500/10 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-blue-300">
Active job: {runningJob.label}
</div>
<div className="mt-0.5 text-[11px] text-blue-100/80">
Manual triggers are blocked until this run finishes.
</div>
</div>
<div className="text-[11px] text-blue-200">
{runningJob.runtime_processed ?? 0}
{typeof runningJob.runtime_total === 'number'
? ` / ${runningJob.runtime_total}`
: ''}
</div>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-700/80">
<div
className="h-full bg-blue-400 transition-all duration-500"
style={{
width: `${
typeof runningJob.runtime_progress_pct === 'number'
? Math.max(5, Math.min(100, runningJob.runtime_progress_pct))
: 30
}%`,
}}
/>
</div>
{runningJob.runtime_current_ticker && (
<div className="mt-1 text-[11px] text-blue-100/80">
Current: {runningJob.runtime_current_ticker}
</div>
)}
{runningJob.runtime_message && (
<div className="mt-1 text-[11px] text-blue-100/80">{runningJob.runtime_message}</div>
)}
</div>
)}
{!runningJob && pausedJob && (
<div className="rounded-xl border border-amber-400/30 bg-amber-500/10 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-amber-300">
Last run paused: {pausedJob.label}
</div>
<div className="mt-0.5 text-[11px] text-amber-100/90">
{pausedJob.runtime_message || 'Rate limit hit. The collector stopped early and will resume from last progress on the next run.'}
</div>
</div>
<div className="text-[11px] text-amber-200">
{pausedJob.runtime_processed ?? 0}
{typeof pausedJob.runtime_total === 'number'
? ` / ${pausedJob.runtime_total}`
: ''}
</div>
</div>
</div>
)}
{groups.map(
(group) =>
group.jobs.length > 0 && (
<section key={group.key} className="space-y-3">
<h3 className="text-xs font-medium uppercase tracking-widest text-gray-500">
{group.title}
<span className="ml-2 num text-gray-600">{group.jobs.length}</span>
<span className="ml-2 normal-case tracking-normal text-gray-600">
{group.hint}
</span>
</h3>
{group.jobs.map((job) => (
<JobCard key={job.name} job={job} {...cardProps} />
))} ))}
</section>
),
)}
</div> </div>
); );
} }