feat(scheduler): flag one-shot tasks in the Scheduled Operations table (#1433)

A one-shot task (delete after successful run) self-deletes only on a
successful run; a failed one-shot is kept so it can be retried or
debugged. The table gave no signal for this, so a lingering failed
one-shot was indistinguishable from a stuck recurring task. Add a
"One-shot" chip to the status cell for every delete-after-run task,
with a tooltip that explains the keep-on-failure behavior when the
task has failed.
This commit is contained in:
Anso
2026-06-24 19:04:42 -04:00
committed by GitHub
parent 5143350771
commit 2c70e11485
2 changed files with 53 additions and 7 deletions
@@ -619,13 +619,28 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
<div className="text-xs text-muted-foreground font-mono">{task.cron_expression}</div>
</TableCell>
<TableCell>
{task.last_status === 'success' ? (
<Badge className="bg-success-muted text-success border-success/20">Success</Badge>
) : task.last_status === 'failure' ? (
<Badge variant="destructive">Failed</Badge>
) : (
<span className="text-xs text-muted-foreground">Never run</span>
)}
<div className="flex items-center gap-1.5">
{task.last_status === 'success' ? (
<Badge className="bg-success-muted text-success border-success/20">Success</Badge>
) : task.last_status === 'failure' ? (
<Badge variant="destructive">Failed</Badge>
) : (
<span className="text-xs text-muted-foreground">Never run</span>
)}
{task.delete_after_run === 1 && (
<Badge
variant="outline"
className="text-[10px] text-muted-foreground"
title={
task.last_status === 'failure'
? 'One-shot task kept after a failed run so you can retry or debug. Deletes itself after a successful run.'
: 'One-shot task. Deletes itself after a successful run.'
}
>
One-shot
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatTimestamp(task.next_run_at)}
@@ -79,6 +79,37 @@ describe('ScheduledOperationsView', () => {
expect(await screen.findByText('nightly-prune')).toBeInTheDocument();
});
it('marks a one-shot task with a chip and leaves recurring tasks unmarked', async () => {
tasksFixture = [
makeTask({ id: 1, name: 'recurring-task' }),
makeTask({ id: 2, name: 'one-shot-task', delete_after_run: 1 }),
];
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
await screen.findByText('recurring-task');
// A single One-shot chip, scoped to the one-shot row.
const chips = screen.getAllByText('One-shot');
expect(chips).toHaveLength(1);
// A pending one-shot carries the plain lifecycle tooltip, not the failure copy.
expect(chips[0]).toHaveAttribute('title', expect.stringContaining('Deletes itself after a successful run'));
expect(chips[0]).toHaveAttribute('title', expect.not.stringContaining('kept after a failed run'));
const oneShotRow = screen.getByText('one-shot-task').closest('tr');
expect(oneShotRow).toContainElement(chips[0]);
const recurringRow = screen.getByText('recurring-task').closest('tr');
expect(recurringRow?.textContent).not.toContain('One-shot');
});
it('explains a failed one-shot was kept to debug', async () => {
tasksFixture = [makeTask({ id: 1, name: 'failed-one-shot', delete_after_run: 1, last_status: 'failure' })];
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
const chip = await screen.findByText('One-shot');
expect(chip).toHaveAttribute('title', expect.stringContaining('kept after a failed run'));
});
it('opens the create modal from a prefill and consumes it once', async () => {
const onPrefillConsumed = vi.fn();
render(