import { useState, useEffect, useCallback } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; import { Combobox } from '@/components/ui/combobox'; import cronstrue from 'cronstrue'; interface ScheduledTask { id: number; name: string; target_type: 'stack' | 'fleet' | 'system'; target_id: string | null; node_id: number | null; action: 'restart' | 'snapshot' | 'prune'; cron_expression: string; enabled: number; created_by: string; created_at: number; updated_at: number; last_run_at: number | null; next_run_at: number | null; last_status: string | null; last_error: string | null; prune_targets: string | null; target_services: string | null; prune_label_filter: string | null; } interface TaskRun { id: number; task_id: number; started_at: number; completed_at: number | null; status: 'running' | 'success' | 'failure'; output: string | null; error: string | null; triggered_by: 'scheduler' | 'manual'; } interface NodeOption { id: number; name: string; } const ACTION_OPTIONS = [ { value: 'restart', label: 'Restart Stack', targetType: 'stack' as const }, { value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' as const }, { value: 'prune', label: 'System Prune', targetType: 'system' as const }, ]; function getCronDescription(expression: string): string { try { return cronstrue.toString(expression); } catch { return 'Invalid expression'; } } function formatTimestamp(ts: number | null): string { if (!ts) return '-'; return new Date(ts).toLocaleString(); } interface ScheduledOperationsViewProps { filterNodeId?: number | null; onClearFilter?: () => void; } export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: ScheduledOperationsViewProps) { const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); 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 [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 fetchTasks = useCallback(async () => { setLoading(true); try { const res = await apiFetch('/scheduled-tasks?exclude_action=update', { 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 (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 = () => { setEditingTask(null); setFormName(''); setFormAction('restart'); setFormTargetId(''); setFormNodeId(filterNodeId != null ? String(filterNodeId) : ''); setFormCron('0 3 * * *'); setFormEnabled(true); setFormPruneTargets(['containers', 'images', 'networks', 'volumes']); setFormTargetServices([]); setFormPruneLabelFilter(''); setDialogOpen(true); if (filterNodeId != null) { fetchStacks(String(filterNodeId)); } }; const openEdit = (task: ScheduledTask) => { setEditingTask(task); setFormName(task.name); setFormAction(task.action); setFormTargetId(task.target_id || ''); setFormNodeId(task.node_id != null ? String(task.node_id) : ''); setFormCron(task.cron_expression); setFormEnabled(task.enabled === 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: formAction, cron_expression: formCron, enabled: formEnabled, }; if (actionOption.targetType === 'stack') { body.target_id = formTargetId; 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'); setDeleteTarget(null); fetchTasks(); } else { const data = await res.json().catch(() => ({})); toast.error(data?.error || 'Failed to delete task'); } } catch { toast.error('Something went wrong.'); } }; 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}" executed successfully`); 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); return (
Scheduled Operations
{filterNodeId != null && filterNodeName && (
Filtered to node: {filterNodeName}
)} {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} {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.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 Dialog */} {editingTask ? 'Edit Scheduled Task' : 'New Scheduled Task'}
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: String(n.id), label: n.name }))} value={formNodeId} onValueChange={setFormNodeId} placeholder="Select node..." />
({ 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 === '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}

{/* Delete Confirmation */} { if (!open) setDeleteTarget(null); }}> Delete Scheduled Task Are you sure you want to delete “{deleteTarget?.name}”? This will also remove all execution history. This action cannot be undone. Cancel Delete {/* Run History Sheet */} { if (!open) setRunsTask(null); }}>
Execution History - {runsTask?.name} {runsTask && runs.length > 0 && ( )}
{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)}

)} )}
); }