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:
@@ -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<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>;
|
||||
}
|
||||
|
||||
export function JobControls() {
|
||||
const { data: jobs, isLoading } = useJobs();
|
||||
const toggleJob = useToggleJob();
|
||||
const triggerJob = useTriggerJob();
|
||||
// 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;
|
||||
|
||||
if (isLoading) return <SkeletonTable rows={4} cols={3} />;
|
||||
|
||||
/** 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<string, string> }) {
|
||||
const name = (id: string) => labels[id] ?? id;
|
||||
if (job.category === 'pipeline' && job.steps?.length) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{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 className="mt-1 text-[11px] leading-relaxed text-gray-600">
|
||||
{job.steps.map(name).join(' → ')}
|
||||
</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 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 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>
|
||||
)}
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
{!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>
|
||||
)}
|
||||
interface JobCardProps {
|
||||
job: JobStatus;
|
||||
labels: Record<string, string>;
|
||||
anyJobRunning: boolean;
|
||||
runningJobLabel?: string;
|
||||
onToggle: (job: JobStatus) => void;
|
||||
onTrigger: (job: JobStatus) => void;
|
||||
togglePending: boolean;
|
||||
triggerPending: boolean;
|
||||
}
|
||||
|
||||
{jobs?.map((job) => (
|
||||
<div key={job.name} className="glass p-4 glass-hover">
|
||||
function JobCard({
|
||||
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 items-center gap-3">
|
||||
{/* Status dot */}
|
||||
@@ -147,7 +134,9 @@ export function JobControls() {
|
||||
/>
|
||||
<div>
|
||||
<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
|
||||
className={`text-[11px] font-medium ${
|
||||
job.running
|
||||
@@ -178,8 +167,8 @@ export function JobControls() {
|
||||
<span className="text-[11px] text-red-400">Not registered</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Persisted, so this survives a deploy — unlike runtime_*,
|
||||
which the status chip above still reads for live state. */}
|
||||
<Membership job={job} labels={labels} />
|
||||
{/* Persisted, so this survives a deploy — unlike runtime_* above. */}
|
||||
{!job.running && job.last_run_at && (
|
||||
<div className={`mt-1 text-[11px] ${lastRunColor(job.last_run_status)}`}>
|
||||
Last run {formatAgo(job.last_run_at)}
|
||||
@@ -187,6 +176,9 @@ export function JobControls() {
|
||||
{job.last_run_message ? ` — ${job.last_run_message}` : ''}
|
||||
</div>
|
||||
)}
|
||||
{!job.running && !job.last_run_at && (
|
||||
<div className="mt-1 text-[11px] text-gray-600">No run recorded yet</div>
|
||||
)}
|
||||
{job.running && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<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>
|
||||
)}
|
||||
</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
|
||||
className="h-full bg-blue-400 transition-all duration-500"
|
||||
style={{
|
||||
@@ -222,8 +214,8 @@ export function JobControls() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleJob.mutate({ jobName: job.name, enabled: !job.enabled })}
|
||||
disabled={toggleJob.isPending}
|
||||
onClick={() => onToggle(job)}
|
||||
disabled={togglePending}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs transition-all duration-200 disabled:opacity-50 ${
|
||||
job.enabled
|
||||
? '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
|
||||
type="button"
|
||||
onClick={() => triggerJob.mutate(job.name)}
|
||||
disabled={triggerJob.isPending || !job.enabled || anyJobRunning}
|
||||
className="btn-primary px-3 py-1.5 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => onTrigger(job)}
|
||||
disabled={triggerPending || !job.enabled || anyJobRunning}
|
||||
className="btn-primary px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span>
|
||||
{job.running
|
||||
? 'Running…'
|
||||
: triggerJob.isPending
|
||||
: triggerPending
|
||||
? 'Triggering…'
|
||||
: anyJobRunning
|
||||
? 'Blocked'
|
||||
@@ -256,7 +248,128 @@ export function JobControls() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user