import { useJobs, useToggleJob, useTriggerJob } from '../../hooks/useAdmin'; import type { JobCategory, JobStatus } from '../../api/admin'; import { SkeletonTable } from '../ui/Skeleton'; function formatNextRun(iso: string | null): string { if (!iso) return '—'; const d = new Date(iso); const now = new Date(); const diffMs = d.getTime() - now.getTime(); if (diffMs < 0) return 'imminent'; const mins = Math.round(diffMs / 60_000); if (mins < 60) return `in ${mins}m`; const hrs = Math.round(mins / 60); if (hrs < 48) return `in ${hrs}h`; return `in ${Math.round(hrs / 24)}d`; } function formatAgo(iso: string | null | undefined): string { if (!iso) return ''; const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; return `${Math.floor(hrs / 24)}d ago`; } function lastRunColor(status: string | null | undefined): string { if (status === 'error') return 'text-red-300'; if (status === 'rate_limited' || status === 'deferred') return 'text-amber-300'; 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 }) { const muted = 'text-[11px] text-gray-500'; if (job.next_run_source === 'manual_only') { return manual only; } if (job.next_run_source === 'via_pipeline') { if (!job.via_next_run_at || !job.via_next_run_job) { return runs via pipeline; } return ( Next via {labels[job.via_next_run_job] ?? job.via_next_run_job}{' '} {formatNextRun(job.via_next_run_at)} ); } if (!job.next_run_at) return null; return Next 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(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 && (
Active job: {runningJob.label}
Manual triggers are blocked until this run finishes.
{runningJob.runtime_processed ?? 0} {typeof runningJob.runtime_total === 'number' ? ` / ${runningJob.runtime_total}` : ''}
{runningJob.runtime_current_ticker && (
Current: {runningJob.runtime_current_ticker}
)} {runningJob.runtime_message && (
{runningJob.runtime_message}
)}
)} {!runningJob && pausedJob && (
Last run paused: {pausedJob.label}
{pausedJob.runtime_message || 'Rate limit hit. The collector stopped early and will resume from last progress on the next run.'}
{pausedJob.runtime_processed ?? 0} {typeof pausedJob.runtime_total === 'number' ? ` / ${pausedJob.runtime_total}` : ''}
)} {groups.map( (group) => group.jobs.length > 0 && (

{group.title} {group.jobs.length} {group.hint}

{group.jobs.map((job) => ( ))}
), )}
); }