import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal'; import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; import { TogglePill } from '@/components/ui/toggle-pill'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download, CalendarClock, Table2, Copy } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { copyToClipboard } from '@/lib/clipboard'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { apiFetch, fetchForNode } from '@/lib/api'; import { excludeLikelySenchoContainers } from '@/lib/senchoContainerFilter'; import { Combobox } from '@/components/ui/combobox'; import { SegmentedControl } from '@/components/ui/segmented-control'; import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling'; import { getCronDescription, getCronFieldError, formatTimestamp, buildCron, parseCron, getSimpleScheduleError, getOnceRunAt, type SimpleSchedule, } from '@/lib/scheduling'; import { ScheduleSimplePanel } from './ScheduleSimplePanel'; import { cn } from '@/lib/utils'; import { SCHEDULED_ACTIONS, SCHEDULED_ACTION_CATEGORIES, getActionById, resolveTaskAction, scheduleTargetDescriptor, DEFAULT_SCHEDULED_ACTION_ID, RISK_BADGE_CLASSES, RISK_DOT_CLASSES, RISK_LABEL, canScheduleAction, canScheduleActionAnywhere, } from '@/lib/scheduledActions'; import { useAuth } from '@/context/AuthContext'; import { LabelNameAutocomplete, type LabelNameSuggestion } from '@/components/labels/LabelNameAutocomplete'; const DEFAULT_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes']; interface LabelMatchPreviewNode { nodeId: number; nodeName: string; reachable: boolean; labelExists: boolean; stackCount: number; stackNames: string[]; error?: string; } interface LabelMatchPreview { matchedNodes: number; matchedStacks: number; unreachableNodes: number; perNode: LabelMatchPreviewNode[]; } const DEFAULT_SIMPLE_SCHEDULE: SimpleSchedule = { frequency: 'daily', minute: 0, hour: 3, weekdays: [], dayOfMonth: 1, date: null, }; const TIMELINE_WINDOW_HOURS = 24; const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000; interface ContainerListItem { Id: string; Names?: string[]; State?: string; Image?: string; Labels?: Record; } function containerDisplayName(c: ContainerListItem): string { return c.Names?.[0]?.replace(/^\//, '') || c.Id.slice(0, 12); } function formatHourTick(ts: number): string { const d = new Date(ts); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } function formatRelative(ts: number, now: number): string { const diff = ts - now; if (diff <= 0) return 'now'; const mins = Math.round(diff / 60000); if (mins < 60) return `in ${mins}m`; const hours = Math.floor(mins / 60); const remMins = mins % 60; return remMins === 0 ? `in ${hours}h` : `in ${hours}h ${remMins}m`; } export interface ScheduleTaskPrefill { stackName: string; nodeId: number | null; } interface ScheduledOperationsViewProps { filterNodeId?: number | null; onClearFilter?: () => void; prefill?: ScheduleTaskPrefill | null; onPrefillConsumed?: () => void; } export default function ScheduledOperationsView({ filterNodeId, onClearFilter, prefill, onPrefillConsumed }: ScheduledOperationsViewProps) { const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [view, setView] = useState<'timeline' | 'table'>('timeline'); const [now, setNow] = useState(() => Date.now()); const [dialogOpen, setDialogOpen] = useState(false); const [editingTask, setEditingTask] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [runsTask, setRunsTask] = useState(null); const [runs, setRuns] = useState([]); const [runsLoading, setRunsLoading] = useState(false); // Form state const [formName, setFormName] = useState(''); const [formAction, setFormAction] = useState('restart'); const [formTargetId, setFormTargetId] = useState(''); const [formNodeId, setFormNodeId] = useState(''); const [formCron, setFormCron] = useState('0 3 * * *'); const [scheduleMode, setScheduleMode] = useState<'simple' | 'advanced'>('simple'); const [simpleSchedule, setSimpleSchedule] = useState(DEFAULT_SIMPLE_SCHEDULE); const [simpleReplacedCron, setSimpleReplacedCron] = useState(false); const [formEnabled, setFormEnabled] = useState(true); const { can, permissions } = useAuth(); const [formDeleteAfterRun, setFormDeleteAfterRun] = useState(false); const [formPruneTargets, setFormPruneTargets] = useState(DEFAULT_PRUNE_TARGETS); const [formTargetServices, setFormTargetServices] = useState([]); const [formPruneLabelFilter, setFormPruneLabelFilter] = useState(''); const [formSelectorValue, setFormSelectorValue] = useState(''); const [formLabelScope, setFormLabelScope] = useState<'fleet' | 'node'>('fleet'); const [labelSuggestions, setLabelSuggestions] = useState([]); const [labelPreview, setLabelPreview] = useState< { kind: 'idle' } | { kind: 'loading' } | { kind: 'unavailable' } | { kind: 'ready'; data: LabelMatchPreview } >({ kind: 'idle' }); const [availableServices, setAvailableServices] = useState([]); const [saving, setSaving] = useState(false); const [runningTaskId, setRunningTaskId] = useState(null); const [runsPage, setRunsPage] = useState(1); const [runsTotal, setRunsTotal] = useState(0); const runsLimit = 20; // Available stacks, containers, and nodes for selection const [stacks, setStacks] = useState([]); const [containers, setContainers] = useState([]); const [nodes, setNodes] = useState([]); const filteredTasks = filterNodeId != null ? tasks.filter(t => t.node_id === filterNodeId) : tasks; const filterNodeName = filterNodeId != null ? nodes.find(n => n.id === filterNodeId)?.name : null; const consumedPrefillRef = useRef(null); const fetchTasks = useCallback(async () => { setLoading(true); try { const res = await apiFetch('/scheduled-tasks', { localOnly: true }); if (res.ok) { setTasks(await res.json()); } } catch { // Non-critical } finally { setLoading(false); } }, []); const fetchStacks = useCallback(async (nodeId?: string) => { try { const res = nodeId ? await fetchForNode('/stacks', parseInt(nodeId, 10)) : await apiFetch('/stacks'); if (res.ok) { setStacks(await res.json()); } } catch { // Non-critical } }, []); const fetchContainers = useCallback(async (nodeId: string) => { try { const res = await fetchForNode('/containers?all=true', parseInt(nodeId, 10)); if (res.ok) { const rows = (await res.json()) as ContainerListItem[]; setContainers(excludeLikelySenchoContainers(rows)); } else { setContainers([]); } } catch { setContainers([]); } }, []); const fetchNodes = useCallback(async () => { try { const res = await apiFetch('/nodes', { localOnly: true }); if (res.ok) { const data = await res.json(); setNodes(data.map((n: { id: number; name: string; type: 'local' | 'remote' }) => ({ id: n.id, name: n.name, type: n.type }))); } } catch { // Non-critical } }, []); useEffect(() => { fetchTasks(); fetchStacks(); fetchNodes(); }, [fetchTasks, fetchStacks, fetchNodes]); useEffect(() => { if (!prefill || prefill === consumedPrefillRef.current) return; consumedPrefillRef.current = prefill; openCreate({ stackName: prefill.stackName, nodeId: prefill.nodeId != null ? String(prefill.nodeId) : '' }); onPrefillConsumed?.(); }, [prefill, onPrefillConsumed]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const id = setInterval(() => setNow(Date.now()), 60_000); return () => clearInterval(id); }, []); useEffect(() => { const actionDef = getActionById(formAction); if (!actionDef?.supportsServiceSelection || !formTargetId) { setAvailableServices([]); return; } let cancelled = false; const fetchServices = async () => { try { // Load services from the selected node so remote-node restart schedules // discover the right services instead of the hub's. const endpoint = `/stacks/${encodeURIComponent(formTargetId)}/services`; const res = formNodeId ? await fetchForNode(endpoint, parseInt(formNodeId, 10)) : await apiFetch(endpoint); if (res.ok && !cancelled) { const services = (await res.json()) as string[]; setAvailableServices(services); if (services.length <= 1) { setFormTargetServices([]); } } } catch { // Non-critical } }; fetchServices(); return () => { cancelled = true; }; }, [formAction, formTargetId, formNodeId]); useEffect(() => { if (!dialogOpen || formAction !== 'update-by-label') return; let cancelled = false; (async () => { try { const res = await apiFetch('/fleet/labels/suggestions', { localOnly: true }); if (!res.ok || cancelled) return; const body = await res.json() as { suggestions?: LabelNameSuggestion[] }; const list = Array.isArray(body.suggestions) ? body.suggestions.filter(s => s && typeof s.name === 'string' && s.scope === 'stack') : []; if (!cancelled) setLabelSuggestions(list); } catch { if (!cancelled) setLabelSuggestions([]); } })(); return () => { cancelled = true; }; }, [dialogOpen, formAction]); useEffect(() => { if (!dialogOpen || formAction !== 'update-by-label') { setLabelPreview({ kind: 'idle' }); return; } const trimmed = formSelectorValue.trim(); if (!trimmed) { setLabelPreview({ kind: 'idle' }); return; } let cancelled = false; setLabelPreview({ kind: 'loading' }); const timer = window.setTimeout(async () => { try { const res = await apiFetch('/fleet/labels/match-preview', { method: 'POST', body: JSON.stringify({ labelName: trimmed }), localOnly: true, }); if (cancelled) return; if (!res.ok) { setLabelPreview({ kind: 'unavailable' }); return; } const data = await res.json() as LabelMatchPreview; if (!data || !Array.isArray(data.perNode)) { setLabelPreview({ kind: 'unavailable' }); return; } setLabelPreview({ kind: 'ready', data }); } catch { if (!cancelled) setLabelPreview({ kind: 'unavailable' }); } }, 500); return () => { cancelled = true; window.clearTimeout(timer); }; }, [dialogOpen, formAction, formSelectorValue]); useEffect(() => { if (!dialogOpen) return; const actionDef = getActionById(formAction); if (actionDef?.requiresContainer && formNodeId) { fetchContainers(formNodeId); fetchStacks(formNodeId); } else if (formNodeId) { fetchStacks(formNodeId); setContainers([]); } else { setStacks([]); setContainers([]); } }, [formNodeId, formAction, dialogOpen, fetchStacks, fetchContainers]); const openCreate = (prefillData?: { stackName: string; nodeId: string }) => { const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : ''); setEditingTask(null); setFormName(''); setFormAction(DEFAULT_SCHEDULED_ACTION_ID); setFormTargetId(prefillData?.stackName ?? ''); setFormNodeId(nodeId); setFormCron('0 3 * * *'); setScheduleMode('simple'); setSimpleSchedule(DEFAULT_SIMPLE_SCHEDULE); setSimpleReplacedCron(false); setFormEnabled(true); setFormDeleteAfterRun(false); setFormPruneTargets(DEFAULT_PRUNE_TARGETS); setFormTargetServices([]); setFormPruneLabelFilter(''); setFormSelectorValue(''); setFormLabelScope('fleet'); setLabelPreview({ kind: 'idle' }); setDialogOpen(true); if (nodeId) fetchStacks(nodeId); }; const openEdit = (task: ScheduledTask) => { setEditingTask(task); setFormName(task.name); setFormAction(resolveTaskAction(task)?.id ?? task.action); setFormTargetId(task.target_id || ''); setFormNodeId(task.node_id != null ? String(task.node_id) : ''); setFormCron(task.cron_expression); let parsed = parseCron(task.cron_expression, (task.delete_after_run ?? 0) === 1); // The cron has no year field, so parseCron reconstructs a one-shot's date in // the current year. Rebuild it from the persisted run_at instead, so editing // (and re-saving) preserves the originally chosen instant rather than moving // it to this year's occurrence. if (parsed && parsed.frequency === 'once' && task.run_at != null) { const pinned = new Date(task.run_at); parsed = { ...parsed, date: pinned, hour: pinned.getHours(), minute: pinned.getMinutes() }; } setScheduleMode(parsed ? 'simple' : 'advanced'); setSimpleSchedule(parsed ?? DEFAULT_SIMPLE_SCHEDULE); setSimpleReplacedCron(false); setFormEnabled(task.enabled === 1); setFormDeleteAfterRun((task.delete_after_run ?? 0) === 1); setFormPruneTargets( task.prune_targets ? JSON.parse(task.prune_targets) : DEFAULT_PRUNE_TARGETS ); setFormTargetServices( task.target_services ? JSON.parse(task.target_services) : [] ); setFormPruneLabelFilter(task.prune_label_filter || ''); setFormSelectorValue(task.selector_value || ''); setFormLabelScope( task.selector_type === 'stack-label' ? (task.node_id == null ? 'fleet' : 'node') : 'fleet', ); setLabelPreview({ kind: 'idle' }); setDialogOpen(true); }; const handleSave = async () => { const actionDef = getActionById(formAction); if (!actionDef) { toast.error('This scheduled action is no longer supported.'); return; } // Re-assert schedule validity at the action, not just via the disabled // button, so the cron is never compiled from an invalid simple schedule. if (scheduleMode === 'simple') { const scheduleError = getSimpleScheduleError(simpleSchedule); if (scheduleError) { toast.error(scheduleError); return; } } // Simple mode compiles its structured fields to the same cron string the // backend stores; Advanced mode sends the raw expression as-is. const cronExpression = scheduleMode === 'simple' ? buildCron(simpleSchedule) : formCron; // A one-time ('once') Simple schedule pins its exact run instant (including // year) via run_at, because the 5-field cron cannot encode a year. null for // every recurring shape and for Advanced mode, where the cron is authoritative. const runAt = scheduleMode === 'simple' ? getOnceRunAt(simpleSchedule) : null; const isLabelUpdate = formAction === 'update-by-label'; const body: Record = { name: formName, target_type: actionDef.targetType, action: actionDef.backendAction, cron_expression: cronExpression, enabled: formEnabled, delete_after_run: formDeleteAfterRun, run_at: runAt, target_id: (actionDef.requiresStack || actionDef.requiresContainer) ? formTargetId : null, node_id: isLabelUpdate ? (formLabelScope === 'node' && formNodeId ? parseInt(formNodeId, 10) : null) : (actionDef.requiresNode && formNodeId ? parseInt(formNodeId, 10) : null), prune_targets: formAction === 'prune' && formPruneTargets.length > 0 ? formPruneTargets : null, target_services: actionDef.supportsServiceSelection && formTargetServices.length > 0 ? formTargetServices : null, prune_label_filter: formAction === 'prune' && formPruneLabelFilter.trim() ? formPruneLabelFilter.trim() : null, selector_type: isLabelUpdate ? 'stack-label' : null, selector_value: isLabelUpdate ? formSelectorValue.trim() : null, }; setSaving(true); try { const res = editingTask ? await apiFetch(`/scheduled-tasks/${editingTask.id}`, { method: 'PUT', body: JSON.stringify(body), localOnly: true }) : await apiFetch('/scheduled-tasks', { method: 'POST', body: JSON.stringify(body), localOnly: true }); if (res.ok) { toast.success(editingTask ? 'Task updated' : 'Task created'); setDialogOpen(false); fetchTasks(); } else { const data = await res.json().catch(() => ({})); toast.error(data?.error || 'Failed to save task'); } } catch (error: unknown) { const msg = error instanceof Error ? error.message : 'Something went wrong.'; toast.error(msg); } finally { setSaving(false); } }; const handleToggle = async (task: ScheduledTask) => { try { const res = await apiFetch(`/scheduled-tasks/${task.id}/toggle`, { method: 'PATCH', localOnly: true }); if (res.ok) { fetchTasks(); } else { const data = await res.json().catch(() => ({})); toast.error(data?.error || 'Failed to toggle task'); } } catch { toast.error('Something went wrong.'); } }; const handleDelete = async () => { if (!deleteTarget) return; try { const res = await apiFetch(`/scheduled-tasks/${deleteTarget.id}`, { method: 'DELETE', localOnly: true }); if (res.ok) { toast.success('Task deleted'); fetchTasks(); } else { const data = await res.json().catch(() => ({})); toast.error(data?.error || 'Failed to delete task'); } } catch { toast.error('Something went wrong.'); } finally { setDeleteTarget(null); } }; const openRuns = async (task: ScheduledTask, page = 1) => { setRunsTask(task); setRunsPage(page); setRunsLoading(true); const offset = (page - 1) * runsLimit; try { const res = await apiFetch(`/scheduled-tasks/${task.id}/runs?limit=${runsLimit}&offset=${offset}`, { localOnly: true }); if (res.ok) { const data = await res.json(); setRuns(data.runs); setRunsTotal(data.total); } } catch { // Non-critical } finally { setRunsLoading(false); } }; const handleRunNow = async (task: ScheduledTask) => { setRunningTaskId(task.id); try { const res = await apiFetch(`/scheduled-tasks/${task.id}/run`, { method: 'POST', localOnly: true }); if (res.ok) { toast.success(`Task "${task.name}" triggered`); fetchTasks(); } else { const data = await res.json().catch(() => ({})); toast.error(data?.error || 'Failed to run task'); } } catch { toast.error('Something went wrong.'); } finally { setRunningTaskId(null); } }; const currentAction = getActionById(formAction); const cronFieldError = getCronFieldError(formCron); const simpleCronError = scheduleMode === 'simple' ? getSimpleScheduleError(simpleSchedule) : null; // In Advanced mode the saved value is the raw input; in Simple mode it is the // compiled cron, short-circuited to '' on a validation error so buildCron is // never reached with an invalid (e.g. dateless one-time) schedule. const derivedCron = scheduleMode === 'simple' ? (simpleCronError ? '' : buildCron(simpleSchedule)) : formCron; // Top-level Simple/Advanced toggle. Advanced -> Simple re-parses the typed // cron and pre-fills when it maps to a simple shape, otherwise flags that the // custom expression will be replaced. Simple -> Advanced seeds the cron input // from what Simple produced so the user keeps what they configured. const handleScheduleModeChange = (mode: 'simple' | 'advanced') => { if (mode === scheduleMode) return; if (mode === 'simple') { const parsed = parseCron(formCron, formDeleteAfterRun); if (parsed) { setSimpleSchedule(parsed); setSimpleReplacedCron(false); } else { setSimpleReplacedCron(true); } } else { if (!simpleCronError) setFormCron(buildCron(simpleSchedule)); setSimpleReplacedCron(false); } setScheduleMode(mode); }; // Selecting the one-time frequency defaults delete-after-run on (the only way // a fully-pinned cron behaves as a single run). Leaving it does not revert. const handleSimpleScheduleChange = (next: SimpleSchedule) => { if (next.frequency === 'once' && simpleSchedule.frequency !== 'once') { setFormDeleteAfterRun(true); } setSimpleSchedule(next); }; const nodeOptions = useMemo(() => nodes.map(n => ({ value: String(n.id), label: n.name })), [nodes]); const nodeNameById = useMemo(() => new Map(nodes.map(n => [n.id, n.name])), [nodes]); const actionOptions = useMemo( () => SCHEDULED_ACTIONS .filter(o => canScheduleActionAnywhere(can, o, permissions)) .map(o => ({ value: o.id, label: o.label, group: SCHEDULED_ACTION_CATEGORIES.find(c => c.key === o.category)?.label, })), [can, permissions], ); // Scan and prune run on the hub-local Docker daemon only; remote nodes are excluded from their pickers. const localNodeOptions = useMemo( () => nodes.filter(n => n.type === 'local').map(n => ({ value: String(n.id), label: n.name })), [nodes], ); const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions; const containerOptions = useMemo( () => containers.map(c => { const name = containerDisplayName(c); const state = c.State ?? 'unknown'; const image = (c.Image ?? '').split('@')[0]; return { value: name, label: `${name} · ${state} · ${image}` }; }), [containers], ); const selectedContainer = useMemo( () => containers.find(c => containerDisplayName(c) === formTargetId), [containers, formTargetId], ); const selectedContainerStack = selectedContainer?.Labels?.['com.docker.compose.project']; const isUnmanagedContainer = !!selectedContainer && ( !selectedContainerStack || !stacks.includes(selectedContainerStack) ); const scheduleInvalid = scheduleMode === 'simple' ? !!simpleCronError : (!formCron || !!cronFieldError); const canSaveWithCurrentTarget = useMemo(() => { if (!currentAction) return false; return canScheduleAction(can, currentAction, { nodeId: formNodeId ? Number(formNodeId) : null, stackName: formTargetId || null, labelScope: formLabelScope === 'node' ? 'node' : 'fleet', }); }, [can, currentAction, formNodeId, formTargetId, formLabelScope]); const isSaveDisabled = saving || !currentAction || !formName || scheduleInvalid || (!!currentAction?.requiresStack && (!formTargetId || !formNodeId)) || (!!currentAction?.requiresContainer && (!formTargetId || !formNodeId)) || (!!currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && !formNodeId) || (formAction === 'prune' && formPruneTargets.length === 0) || (formAction === 'update-by-label' && ( !formSelectorValue.trim() || (formLabelScope === 'node' && !formNodeId) )) || !canSaveWithCurrentTarget; const saveDisabledReason = useMemo((): string | null => { if (saving || !currentAction || !formName || scheduleInvalid) return null; if (!canSaveWithCurrentTarget) { return 'You do not have permission to schedule this action on the selected target.'; } return null; }, [saving, currentAction, formName, scheduleInvalid, canSaveWithCurrentTarget]); const windowEnd = now + TIMELINE_WINDOW_MS; const timelinePills = filteredTasks .filter(t => t.enabled === 1 && t.next_runs && t.next_runs.length > 0) .flatMap(task => (task.next_runs ?? []).map(runAt => ({ task, runAt }))) .filter(p => p.runAt >= now && p.runAt <= windowEnd) .sort((a, b) => a.runAt - b.runAt); const nextPill = timelinePills[0] ?? null; const hourTicks = Array.from({ length: 6 }, (_, i) => now + (i / 5) * TIMELINE_WINDOW_MS); const windowStartLabel = new Date(now).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); const windowEndLabel = new Date(windowEnd).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); return (
Scheduled Operations
value={view} options={[ { value: 'timeline', label: 'Timeline', icon: CalendarClock }, { value: 'table', label: 'All tasks', icon: Table2 }, ]} onChange={(v) => setView(v)} />
{filterNodeId != null && filterNodeName && (
Filtered to node: {filterNodeName}
)} {view === 'timeline' ? (
Next 24 hours
Next 24 hours
{windowStartLabel} {formatHourTick(now)} → {windowEndLabel} {formatHourTick(windowEnd)}
{nextPill ? (
Next
{formatHourTick(nextPill.runAt)}
{nextPill.task.name} · {formatRelative(nextPill.runAt, now)}
) : (
Next
--:--
Nothing scheduled
)}
{loading && filteredTasks.length === 0 ? (
Loading...
) : (
{SCHEDULED_ACTION_CATEGORIES.map(lane => { const lanePills = timelinePills.filter(p => resolveTaskAction(p.task)?.category === lane.key); return (
{lanePills.map((pill, idx) => { const leftPct = ((pill.runAt - now) / TIMELINE_WINDOW_MS) * 100; const clamped = Math.max(0, Math.min(100, leftPct)); const nodeName = pill.task.node_id != null ? nodeNameById.get(pill.task.node_id) : undefined; const targetLabel = scheduleTargetDescriptor(pill.task, nodeName); const actionLabel = resolveTaskAction(pill.task)?.label ?? pill.task.action; const tooltip = `${actionLabel} · ${pill.task.name} · ${formatHourTick(pill.runAt)}` + (nodeName ? ` · ${nodeName}` : ''); return ( ); })}
); })}
{/* Now rail */} ) : loading && filteredTasks.length === 0 ? (
Loading...
) : filteredTasks.length === 0 ? (
{filterNodeId != null ? 'No scheduled tasks for this node. Create one to automate recurring operations.' : 'No scheduled tasks yet. Create one to automate recurring operations.'}
) : ( Name Action Target Schedule Status Next Run Enabled Actions {filteredTasks.map((task) => ( {task.name} {resolveTaskAction(task)?.label || task.action} {task.selector_type === 'stack-label' && task.selector_value ? scheduleTargetDescriptor( task, task.node_id != null ? nodes.find(n => n.id === task.node_id)?.name : undefined, ) : task.target_type === 'stack' ? task.target_services ? `${task.target_id} (${(JSON.parse(task.target_services) as string[]).join(', ')})` : task.target_id : task.target_type === 'container' ? task.target_id : task.action === 'update' ? 'All eligible stacks' : task.target_type}
{getCronDescription(task.cron_expression)}
{task.cron_expression}
{task.last_status === 'success' ? ( Success ) : task.last_status === 'failure' ? ( Failed ) : ( Never run )} {task.delete_after_run === 1 && ( One-shot )}
{formatTimestamp(task.next_run_at)} handleToggle(task)} />
Run now Execution history Edit Delete
))}
)} {/* Create/Edit Modal */}
setFormName(e.target.value)} />
{ setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); setFormSelectorValue(''); setFormLabelScope('fleet'); setLabelPreview({ kind: 'idle' }); }} placeholder="Select action..." /> {currentAction && (
{RISK_LABEL[currentAction.riskLevel]}

{currentAction.helperText}

)}
{currentAction?.requiresContainer && ( <>
{ setFormNodeId(val); setFormTargetId(''); }} placeholder="Select node..." />
{ setFormTargetId(val); setFormTargetServices([]); }} placeholder={formNodeId ? 'Select container...' : 'Select a node first'} disabled={!formNodeId} />
{isUnmanagedContainer && (

This container is not associated with a Sencho stack. The schedule will target the container by node and name.

)} {selectedContainerStack && stacks.includes(selectedContainerStack) && (

Part of stack: {selectedContainerStack}

)} )} {currentAction?.requiresStack && ( <>
{ setFormNodeId(val); setFormTargetId(''); }} placeholder="Select node..." />
({ value: s, label: s }))} value={formTargetId} onValueChange={(val) => { setFormTargetId(val); setFormTargetServices([]); }} placeholder={formNodeId ? "Select stack..." : "Select a node first"} disabled={!formNodeId} />
{currentAction.supportsServiceSelection && formTargetId && availableServices.length > 1 && (
{availableServices.map(svc => ( ))}
)} )} {formAction === 'snapshot' && (
Entire fleet

Captures every node's compose and .env files. No node or stack to choose.

)} {formAction === 'update-by-label' && ( <>
value={formLabelScope} options={[ { value: 'fleet', label: 'Entire fleet' }, { value: 'node', label: 'Selected node' }, ]} onChange={(v) => { setFormLabelScope(v); if (v === 'fleet') setFormNodeId(''); }} ariaLabel="Label update scope" />
{formLabelScope === 'node' && (
)}
Current matches
{labelPreview.kind === 'idle' && (

Enter a label name to preview matching stacks.

)} {labelPreview.kind === 'loading' && (

Resolving label membership...

)} {labelPreview.kind === 'unavailable' && (

Preview unavailable. You can still save; membership is resolved at run time.

)} {labelPreview.kind === 'ready' && (() => { const scoped = formLabelScope === 'node' && formNodeId ? { ...labelPreview.data, perNode: labelPreview.data.perNode.filter(n => String(n.nodeId) === formNodeId), } : labelPreview.data; const matchedStacks = scoped.perNode .filter(n => n.reachable) .reduce((sum, n) => sum + n.stackCount, 0); const reachableNodes = scoped.perNode.filter(n => n.reachable && n.stackCount > 0).length; const unreachable = scoped.perNode.filter(n => !n.reachable); return (

{matchedStacks} stack{matchedStacks === 1 ? '' : 's'} on {reachableNodes} node{reachableNodes === 1 ? '' : 's'} {unreachable.length > 0 ? ` · ${unreachable.length} unreachable` : ''}

{matchedStacks === 0 && (

No stacks currently match this label. You can still save; membership is resolved at each run.

)}
    {scoped.perNode.map(n => (
  • {n.nodeName}: {n.reachable ? (n.stackCount > 0 ? n.stackNames.slice(0, 8).join(', ') + (n.stackNames.length > 8 ? '…' : '') : 'no match') : `unreachable${n.error ? ` (${n.error})` : ''}`}
  • ))}
); })()}
)} {currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && (
)} {formAction === 'prune' && ( <>
{DEFAULT_PRUNE_TARGETS.map(target => ( ))}
setFormPruneLabelFilter(e.target.value)} className="font-mono text-xs" />

Only prune resources matching this Docker label.

)}
value={scheduleMode} options={[{ value: 'simple', label: 'Simple' }, { value: 'advanced', label: 'Advanced' }]} onChange={handleScheduleModeChange} ariaLabel="Schedule mode" />
{scheduleMode === 'simple' ? ( <> {simpleReplacedCron && (

Switching to Simple mode replaces your custom cron expression.

)} ) : ( <> setFormCron(e.target.value)} className="font-mono" /> {cronFieldError ?

{cronFieldError}

:

{getCronDescription(formCron)}

} )}
setDialogOpen(false)}>Cancel } primary={ } /> {saveDisabledReason && (

{saveDisabledReason}

)}
{/* Delete Confirmation */} { if (!open) setDeleteTarget(null); }} variant="destructive" kicker="SCHEDULER · DELETE · IRREVERSIBLE" title="Delete scheduled task" confirmLabel="Delete" onConfirm={handleDelete} >

Permanently deletes {deleteTarget?.name} and all of its execution history.

{/* Run History Sheet */} { if (!open) setRunsTask(null); }} crumb={['Schedules', runsTask?.name ?? '—', 'Runs']} name={runsTask?.name ?? 'Run history'} meta={`${runsTotal} run${runsTotal === 1 ? '' : 's'}`} secondaryActions={runsTask && runs.length > 0 ? [{ label: 'Download CSV', icon: Download, onClick: () => window.open(`/api/scheduled-tasks/${runsTask.id}/runs/export`, '_blank'), }] : undefined} footerContext={runsTask?.next_run_at ? `Next run ${formatTimestamp(runsTask.next_run_at)}` : undefined} size="lg" > {runsLoading ? (
Loading...
) : runs.length === 0 ? (
No executions yet.
) : ( <> Time Source Status Duration Details {runs.map((run) => { const duration = run.completed_at && run.started_at ? `${((run.completed_at - run.started_at) / 1000).toFixed(1)}s` : '-'; return ( {new Date(run.started_at).toLocaleString()} {run.triggered_by === 'manual' ? 'Manual' : 'Scheduled'} {run.status === 'success' ? ( Success ) : run.status === 'failure' ? ( Failed ) : ( Running )} {duration}
{run.error || run.output || '-'} {(run.output || run.error) ? ( Copy details ) : null}
); })}
{Math.ceil(runsTotal / runsLimit) > 1 && runsTask && (

Page {runsPage} of {Math.ceil(runsTotal / runsLimit)}

)} )}
); }