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 { 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,21 +75,222 @@ 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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="mt-1 text-[11px] leading-relaxed text-gray-600">
|
||||||
|
{job.steps.map(name).join(' → ')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JobCardProps {
|
||||||
|
job: JobStatus;
|
||||||
|
labels: Record<string, string>;
|
||||||
|
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 (
|
||||||
|
<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 */}
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2.5 w-2.5 rounded-full shrink-0 ${
|
||||||
|
job.running
|
||||||
|
? 'bg-blue-400 shadow-lg shadow-blue-400/40'
|
||||||
|
: job.enabled
|
||||||
|
? 'bg-emerald-400 shadow-lg shadow-emerald-400/40'
|
||||||
|
: 'bg-gray-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-200">{job.label}</span>
|
||||||
|
<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
|
||||||
|
? 'text-blue-300'
|
||||||
|
: job.runtime_status === 'rate_limited' || job.runtime_status === 'deferred'
|
||||||
|
? 'text-amber-300'
|
||||||
|
: job.runtime_status === 'error'
|
||||||
|
? 'text-red-300'
|
||||||
|
: job.enabled
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{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'}
|
||||||
|
</span>
|
||||||
|
{job.enabled && <NextRun job={job} labels={labels} />}
|
||||||
|
{!job.registered && (
|
||||||
|
<span className="text-[11px] text-red-400">Not registered</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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)}
|
||||||
|
{job.last_run_status ? ` · ${job.last_run_status}` : ''}
|
||||||
|
{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">
|
||||||
|
<span>
|
||||||
|
{job.runtime_processed ?? 0}
|
||||||
|
{typeof job.runtime_total === 'number' ? ` / ${job.runtime_total}` : ''}
|
||||||
|
{' '}processed
|
||||||
|
</span>
|
||||||
|
{typeof job.runtime_progress_pct === 'number' && (
|
||||||
|
<span>{Math.max(0, Math.min(100, job.runtime_progress_pct)).toFixed(0)}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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={{
|
||||||
|
width: `${
|
||||||
|
typeof job.runtime_progress_pct === 'number'
|
||||||
|
? Math.max(5, Math.min(100, job.runtime_progress_pct))
|
||||||
|
: 30
|
||||||
|
}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{job.runtime_current_ticker && (
|
||||||
|
<div className="text-[11px] text-gray-500">Current: {job.runtime_current_ticker}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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'
|
||||||
|
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{job.enabled ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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…'
|
||||||
|
: triggerPending
|
||||||
|
? 'Triggering…'
|
||||||
|
: anyJobRunning
|
||||||
|
? 'Blocked'
|
||||||
|
: 'Trigger Now'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{anyJobRunning && !job.running && (
|
||||||
|
<div className="mt-2 text-[11px] text-gray-500">
|
||||||
|
Manual trigger blocked while {runningJobLabel ?? 'another job'} is running.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function JobControls() {
|
export function JobControls() {
|
||||||
const { data: jobs, isLoading } = useJobs();
|
const { data: jobs, isLoading } = useJobs();
|
||||||
const toggleJob = useToggleJob();
|
const toggleJob = useToggleJob();
|
||||||
const triggerJob = useTriggerJob();
|
const triggerJob = useTriggerJob();
|
||||||
|
const all = jobs ?? [];
|
||||||
// Job id -> display label, so a step can name its parent pipeline.
|
// Job id -> display label, so a step can name its parent pipeline.
|
||||||
const labels = Object.fromEntries((jobs ?? []).map((job) => [job.name, job.label]));
|
const labels = Object.fromEntries(all.map((job) => [job.name, job.label]));
|
||||||
const anyJobRunning = (jobs ?? []).some((job) => job.running);
|
const anyJobRunning = all.some((job) => job.running);
|
||||||
const runningJob = jobs?.find((job) => job.running);
|
const runningJob = all.find((job) => job.running);
|
||||||
const pausedJob = jobs?.find((job) => !job.running && job.runtime_status === 'rate_limited');
|
const pausedJob = all.find((job) => !job.running && job.runtime_status === 'rate_limited');
|
||||||
const runningJobLabel = runningJob?.label;
|
|
||||||
|
|
||||||
if (isLoading) return <SkeletonTable rows={4} cols={3} />;
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-6">
|
||||||
{runningJob && (
|
{runningJob && (
|
||||||
<div className="rounded-xl border border-blue-400/30 bg-blue-500/10 px-4 py-3">
|
<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 className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -85,7 +309,7 @@ export function JobControls() {
|
|||||||
: ''}
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-1.5 w-full rounded-full bg-slate-700/80 overflow-hidden">
|
<div className="mt-2 h-1.5 w-full 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={{
|
||||||
@@ -103,9 +327,7 @@ export function JobControls() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{runningJob.runtime_message && (
|
{runningJob.runtime_message && (
|
||||||
<div className="mt-1 text-[11px] text-blue-100/80">
|
<div className="mt-1 text-[11px] text-blue-100/80">{runningJob.runtime_message}</div>
|
||||||
{runningJob.runtime_message}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -131,132 +353,23 @@ export function JobControls() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{jobs?.map((job) => (
|
{groups.map(
|
||||||
<div key={job.name} className="glass p-4 glass-hover">
|
(group) =>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
group.jobs.length > 0 && (
|
||||||
<div className="flex items-center gap-3">
|
<section key={group.key} className="space-y-3">
|
||||||
{/* Status dot */}
|
<h3 className="text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||||
<span
|
{group.title}
|
||||||
className={`inline-block h-2.5 w-2.5 rounded-full shrink-0 ${
|
<span className="ml-2 num text-gray-600">{group.jobs.length}</span>
|
||||||
job.running
|
<span className="ml-2 normal-case tracking-normal text-gray-600">
|
||||||
? 'bg-blue-400 shadow-lg shadow-blue-400/40'
|
{group.hint}
|
||||||
: job.enabled
|
|
||||||
? 'bg-emerald-400 shadow-lg shadow-emerald-400/40'
|
|
||||||
: 'bg-gray-500'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium text-gray-200">{job.label}</span>
|
|
||||||
<div className="flex items-center gap-3 mt-0.5">
|
|
||||||
<span
|
|
||||||
className={`text-[11px] font-medium ${
|
|
||||||
job.running
|
|
||||||
? 'text-blue-300'
|
|
||||||
: job.runtime_status === 'rate_limited' || job.runtime_status === 'deferred'
|
|
||||||
? 'text-amber-300'
|
|
||||||
: job.runtime_status === 'error'
|
|
||||||
? 'text-red-300'
|
|
||||||
: job.enabled
|
|
||||||
? 'text-emerald-400'
|
|
||||||
: 'text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{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'}
|
|
||||||
</span>
|
|
||||||
{job.enabled && <NextRun job={job} labels={labels} />}
|
|
||||||
{!job.registered && (
|
|
||||||
<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. */}
|
|
||||||
{!job.running && job.last_run_at && (
|
|
||||||
<div className={`mt-1 text-[11px] ${lastRunColor(job.last_run_status)}`}>
|
|
||||||
Last run {formatAgo(job.last_run_at)}
|
|
||||||
{job.last_run_status ? ` · ${job.last_run_status}` : ''}
|
|
||||||
{job.last_run_message ? ` — ${job.last_run_message}` : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{job.running && (
|
|
||||||
<div className="mt-2 space-y-1.5">
|
|
||||||
<div className="flex items-center justify-between text-[11px] text-gray-400">
|
|
||||||
<span>
|
|
||||||
{job.runtime_processed ?? 0}
|
|
||||||
{typeof job.runtime_total === 'number' ? ` / ${job.runtime_total}` : ''}
|
|
||||||
{' '}processed
|
|
||||||
</span>
|
|
||||||
{typeof job.runtime_progress_pct === 'number' && (
|
|
||||||
<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-full bg-blue-400 transition-all duration-500"
|
|
||||||
style={{
|
|
||||||
width: `${
|
|
||||||
typeof job.runtime_progress_pct === 'number'
|
|
||||||
? Math.max(5, Math.min(100, job.runtime_progress_pct))
|
|
||||||
: 30
|
|
||||||
}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{job.runtime_current_ticker && (
|
|
||||||
<div className="text-[11px] text-gray-500">Current: {job.runtime_current_ticker}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleJob.mutate({ jobName: job.name, enabled: !job.enabled })}
|
|
||||||
disabled={toggleJob.isPending}
|
|
||||||
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'
|
|
||||||
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{job.enabled ? 'Disable' : 'Enable'}
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{job.running
|
|
||||||
? 'Running…'
|
|
||||||
: triggerJob.isPending
|
|
||||||
? 'Triggering…'
|
|
||||||
: anyJobRunning
|
|
||||||
? 'Blocked'
|
|
||||||
: 'Trigger Now'}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</h3>
|
||||||
</div>
|
{group.jobs.map((job) => (
|
||||||
</div>
|
<JobCard key={job.name} job={job} {...cardProps} />
|
||||||
{anyJobRunning && !job.running && (
|
))}
|
||||||
<div className="mt-2 text-[11px] text-gray-500">
|
</section>
|
||||||
Manual trigger blocked while {runningJobLabel ?? 'another job'} is running.
|
),
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user