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 } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; import { Combobox } from '@/components/ui/combobox'; import { useLicense } from '@/context/LicenseContext'; import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling'; import { getCronDescription, formatTimestamp } from '@/lib/scheduling'; const UPDATE_FLEET_ACTION = 'update-fleet' as const; // Mirrors backend `SKIPPER_SCHEDULED_ACTIONS` in tierGates.ts. Picker options // whose backend action falls outside this set are Admiral-only and hidden from // Skipper users so the Combobox never offers a choice the API will reject. const SKIPPER_BACKEND_ACTIONS: ReadonlySet = new Set(['update', 'scan', 'snapshot']); function isActionAllowedForVariant(option: { value: string; backendAction?: string }, variant: string | null | undefined): boolean { if (variant === 'admiral') return true; return SKIPPER_BACKEND_ACTIONS.has(option.backendAction ?? option.value); } const ACTION_OPTIONS: Array<{ value: string; label: string; targetType: 'stack' | 'fleet' | 'system'; backendAction?: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan'; }> = [ { value: 'restart', label: 'Restart Stack', targetType: 'stack' }, { value: 'update', label: 'Auto-update Stack', targetType: 'stack' }, { value: UPDATE_FLEET_ACTION, label: 'Auto-update All Stacks', targetType: 'fleet', backendAction: 'update' }, { value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' }, { value: 'prune', label: 'System Prune', targetType: 'system' }, { value: 'scan', label: 'Vulnerability Scan', targetType: 'system' }, { value: 'auto_backup', label: 'Backup Stack Files', targetType: 'stack' }, { value: 'auto_stop', label: 'Stop Stack (keep containers)', targetType: 'stack' }, { value: 'auto_down', label: 'Take Stack Down (remove containers)', targetType: 'stack' }, { value: 'auto_start', label: 'Start Stack', targetType: 'stack' }, ]; const TIMELINE_LANES: { key: ScheduledTask['action']; label: string; color: string; bg: string; actions: ScheduledTask['action'][] }[] = [ { key: 'restart', label: 'Restart', color: 'var(--brand)', bg: 'oklch(from var(--brand) l c h / 0.18)', actions: ['restart'] }, { key: 'update', label: 'Update', color: 'var(--success)', bg: 'oklch(from var(--success) l c h / 0.18)', actions: ['update'] }, { key: 'scan', label: 'Scan', color: 'var(--label-purple)', bg: 'var(--label-purple-bg)', actions: ['scan'] }, { key: 'prune', label: 'Prune', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)', actions: ['prune', 'snapshot'] }, { key: 'auto_stop', label: 'Lifecycle', color: 'var(--label-blue)', bg: 'var(--label-blue-bg)', actions: ['auto_stop', 'auto_down', 'auto_start', 'auto_backup'] }, ]; const TIMELINE_WINDOW_HOURS = 24; const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000; 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 { license } = useLicense(); const visibleActionOptions = useMemo( () => ACTION_OPTIONS.filter(o => isActionAllowedForVariant(o, license?.variant)), [license?.variant] ); 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 [formEnabled, setFormEnabled] = useState(true); const [formDeleteAfterRun, setFormDeleteAfterRun] = useState(false); const [formPruneTargets, setFormPruneTargets] = useState(['containers', 'images', 'networks', 'volumes']); const [formTargetServices, setFormTargetServices] = useState([]); const [formPruneLabelFilter, setFormPruneLabelFilter] = useState(''); 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 and nodes for selection const [stacks, setStacks] = 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 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 }) => ({ id: n.id, name: n.name }))); } } 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(() => { if (formAction !== 'restart' || !formTargetId) { setAvailableServices([]); return; } let cancelled = false; const fetchServices = async () => { try { const res = await apiFetch(`/stacks/${encodeURIComponent(formTargetId)}/services`); if (res.ok && !cancelled) { setAvailableServices(await res.json()); } } catch { // Non-critical } }; fetchServices(); return () => { cancelled = true; }; }, [formAction, formTargetId]); // Re-fetch stacks when node changes useEffect(() => { if (!dialogOpen) return; if (formNodeId) { fetchStacks(formNodeId); setFormTargetId(''); } else { setStacks([]); } }, [formNodeId, dialogOpen, fetchStacks]); const openCreate = (prefillData?: { stackName: string; nodeId: string }) => { const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : ''); setEditingTask(null); setFormName(''); setFormAction(visibleActionOptions[0]?.value ?? 'restart'); setFormTargetId(prefillData?.stackName ?? ''); setFormNodeId(nodeId); setFormCron('0 3 * * *'); setFormEnabled(true); setFormDeleteAfterRun(false); setFormPruneTargets(['containers', 'images', 'networks', 'volumes']); setFormTargetServices([]); setFormPruneLabelFilter(''); setDialogOpen(true); if (nodeId) fetchStacks(nodeId); }; const openEdit = (task: ScheduledTask) => { setEditingTask(task); setFormName(task.name); setFormAction(task.action === 'update' && task.target_type === 'fleet' ? UPDATE_FLEET_ACTION : task.action); setFormTargetId(task.target_id || ''); setFormNodeId(task.node_id != null ? String(task.node_id) : ''); setFormCron(task.cron_expression); setFormEnabled(task.enabled === 1); setFormDeleteAfterRun((task.delete_after_run ?? 0) === 1); setFormPruneTargets( task.prune_targets ? JSON.parse(task.prune_targets) : ['containers', 'images', 'networks', 'volumes'] ); setFormTargetServices( task.target_services ? JSON.parse(task.target_services) : [] ); setFormPruneLabelFilter(task.prune_label_filter || ''); setDialogOpen(true); }; const handleSave = async () => { const actionOption = ACTION_OPTIONS.find(a => a.value === formAction); if (!actionOption) return; const body: Record = { name: formName, target_type: actionOption.targetType, action: actionOption.backendAction ?? formAction, cron_expression: formCron, enabled: formEnabled, delete_after_run: formDeleteAfterRun, }; if (actionOption.targetType === 'stack') { body.target_id = formTargetId; body.node_id = formNodeId ? parseInt(formNodeId, 10) : null; } if (formAction === 'scan' || formAction === UPDATE_FLEET_ACTION) { body.node_id = formNodeId ? parseInt(formNodeId, 10) : null; } if (formAction === 'prune' && formPruneTargets.length > 0) { body.prune_targets = formPruneTargets; } if (formAction === 'restart' && formTargetServices.length > 0) { body.target_services = formTargetServices; } if (formAction === 'prune' && formPruneLabelFilter.trim()) { body.prune_label_filter = formPruneLabelFilter.trim(); } 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 targetType = ACTION_OPTIONS.find(a => a.value === formAction)?.targetType; const cronDescription = getCronDescription(formCron); const nodeOptions = useMemo(() => nodes.map(n => ({ value: String(n.id), label: n.name })), [nodes]); const isSaveDisabled = saving || !formName || !formCron || (targetType === 'stack' && (!formTargetId || !formNodeId)) || (formAction === 'scan' && !formNodeId) || (formAction === UPDATE_FLEET_ACTION && !formNodeId) || (formAction === 'prune' && formPruneTargets.length === 0); 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
{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...
) : (
{TIMELINE_LANES.map(lane => { const lanePills = timelinePills.filter(p => lane.actions.includes(p.task.action)); return (
{lanePills.map((pill, idx) => { const leftPct = ((pill.runAt - now) / TIMELINE_WINDOW_MS) * 100; const clamped = Math.max(0, Math.min(100, leftPct)); const targetLabel = pill.task.target_type === 'stack' ? pill.task.target_id ?? pill.task.name : pill.task.name; 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} {(task.action === 'update' && task.target_type === 'fleet' ? ACTION_OPTIONS.find(a => a.value === UPDATE_FLEET_ACTION) : ACTION_OPTIONS.find(a => a.value === task.action) )?.label || task.action} {task.target_type === 'stack' ? task.target_services ? `${task.target_id} (${(JSON.parse(task.target_services) as string[]).join(', ')})` : 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 )} {formatTimestamp(task.next_run_at)} handleToggle(task)} />
))}
)} {/* Create/Edit Modal */}
setFormName(e.target.value)} />
({ value: o.value, label: o.label }))} value={formAction} onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }} placeholder="Select action..." />
{targetType === 'stack' && ( <>
({ value: s, label: s }))} value={formTargetId} onValueChange={setFormTargetId} placeholder={formNodeId ? "Select stack..." : "Select a node first"} disabled={!formNodeId} />
{formAction === 'restart' && formTargetId && availableServices.length > 0 && (
{availableServices.map(svc => ( ))}
)} )} {formAction === UPDATE_FLEET_ACTION && (

Only stacks with auto-updates enabled on this node will be updated.

)} {formAction === 'scan' && (

Every image on the selected node will be scanned.

)} {formAction === 'prune' && ( <>
{['containers', 'images', 'networks', 'volumes'].map(target => ( ))}
setFormPruneLabelFilter(e.target.value)} className="font-mono text-xs" />

Only prune resources matching this Docker label.

)}
setFormCron(e.target.value)} className="font-mono" />

{cronDescription}

setDialogOpen(false)}>Cancel } primary={ } />
{/* 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 || '-'} ); })}
{Math.ceil(runsTotal / runsLimit) > 1 && runsTask && (

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

)} )}
); }