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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Combobox } from '@/components/ui/combobox'; 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 { RefreshCw, Plus, Pencil, Trash2, History, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; import { PaidGate } from '@/components/PaidGate'; 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' | 'update'; 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; } 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 CRON_PRESETS = [ { label: 'Every 6 hours', value: '0 */6 * * *' }, { label: 'Every 12 hours', value: '0 */12 * * *' }, { label: 'Daily at 3 AM', value: '0 3 * * *' }, { label: 'Daily at midnight', value: '0 0 * * *' }, { label: 'Weekly (Sunday 3 AM)', value: '0 3 * * 0' }, { label: 'Custom', value: 'custom' }, ]; 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 AutoUpdatePoliciesProps { filterNodeId?: number | null; onClearFilter?: () => void; } function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) { const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); const [editingPolicy, setEditingPolicy] = 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 [formTargetId, setFormTargetId] = useState(''); const [formNodeId, setFormNodeId] = useState(''); const [formCron, setFormCron] = useState('0 3 * * *'); const [formCronPreset, setFormCronPreset] = useState('0 3 * * *'); const [formEnabled, setFormEnabled] = useState(true); const [saving, setSaving] = useState(false); const [runningPolicyId, setRunningPolicyId] = useState(null); const [runsPage, setRunsPage] = useState(1); const [runsTotal, setRunsTotal] = useState(0); const runsLimit = 20; // Available stacks and nodes const [stacks, setStacks] = useState([]); const [nodes, setNodes] = useState([]); const filteredPolicies = filterNodeId != null ? policies.filter(p => p.node_id === filterNodeId) : policies; const filterNodeName = filterNodeId != null ? nodes.find(n => n.id === filterNodeId)?.name : null; const fetchPolicies = useCallback(async () => { setLoading(true); try { const res = await apiFetch('/scheduled-tasks?action=update', { localOnly: true }); if (res.ok) { setPolicies(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()); else setStacks([]); } catch { setStacks([]); } }, []); 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(() => { fetchPolicies(); fetchStacks(); fetchNodes(); }, [fetchPolicies, fetchStacks, fetchNodes]); // Re-fetch stacks when selected node changes in the dialog useEffect(() => { if (dialogOpen && formNodeId) { fetchStacks(formNodeId); setFormTargetId(''); } }, [formNodeId, dialogOpen, fetchStacks]); const openCreate = () => { setEditingPolicy(null); setFormName(''); setFormTargetId(''); setFormNodeId(filterNodeId != null ? String(filterNodeId) : ''); setFormCron('0 3 * * *'); setFormCronPreset('0 3 * * *'); setFormEnabled(true); setDialogOpen(true); if (filterNodeId != null) { fetchStacks(String(filterNodeId)); } }; const openEdit = (policy: ScheduledTask) => { setEditingPolicy(policy); setFormName(policy.name); setFormTargetId(policy.target_id || ''); setFormNodeId(policy.node_id != null ? String(policy.node_id) : ''); setFormCron(policy.cron_expression); const matchingPreset = CRON_PRESETS.find(p => p.value === policy.cron_expression); setFormCronPreset(matchingPreset ? matchingPreset.value : 'custom'); setFormEnabled(policy.enabled === 1); setDialogOpen(true); }; const handleSave = async () => { const body: Record = { name: formName, target_type: 'stack', action: 'update', target_id: formTargetId, node_id: formNodeId ? parseInt(formNodeId, 10) : null, cron_expression: formCron, enabled: formEnabled, }; setSaving(true); try { const res = editingPolicy ? await apiFetch(`/scheduled-tasks/${editingPolicy.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(editingPolicy ? 'Policy updated' : 'Policy created'); setDialogOpen(false); fetchPolicies(); } else { const data = await res.json().catch(() => ({})); toast.error(data?.error || 'Failed to save policy'); } } catch (error: unknown) { const msg = error instanceof Error ? error.message : 'Something went wrong.'; toast.error(msg); } finally { setSaving(false); } }; const handleToggle = async (policy: ScheduledTask) => { try { const res = await apiFetch(`/scheduled-tasks/${policy.id}/toggle`, { method: 'PATCH', localOnly: true }); if (res.ok) { fetchPolicies(); } else { const data = await res.json().catch(() => ({})); toast.error(data?.error || 'Failed to toggle policy'); } } 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('Policy deleted'); setDeleteTarget(null); fetchPolicies(); } else { const data = await res.json().catch(() => ({})); toast.error(data?.error || 'Failed to delete policy'); } } 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 (policy: ScheduledTask) => { setRunningPolicyId(policy.id); try { const res = await apiFetch(`/scheduled-tasks/${policy.id}/run`, { method: 'POST', localOnly: true }); if (res.ok) { toast.success(`Checking for updates on "${policy.target_id}"...`); fetchPolicies(); } else { const data = await res.json().catch(() => ({})); toast.error(data?.error || 'Failed to run policy'); } } catch { toast.error('Something went wrong.'); } finally { setRunningPolicyId(null); } }; const cronDescription = getCronDescription(formCron); return (
Auto-Update Policies

Automatically check for new images and update your stacks on a schedule.

{filterNodeId != null && filterNodeName && (
Filtered to node: {filterNodeName}
)} {loading && filteredPolicies.length === 0 ? (
Loading...
) : filteredPolicies.length === 0 ? (
{filterNodeId != null ? 'No auto-update policies for this node. Create one to keep your stacks up to date automatically.' : 'No auto-update policies yet. Create one to keep your stacks up to date automatically.'}
) : ( Name Stack Schedule Status Last Run Next Run Enabled Actions {filteredPolicies.map((policy) => ( {policy.name} {policy.target_id === '*' ? 'All Stacks' : policy.target_id}
{getCronDescription(policy.cron_expression)}
{policy.cron_expression}
{policy.last_status === 'success' ? ( Success ) : policy.last_status === 'failure' ? ( Failed ) : ( Never run )} {formatTimestamp(policy.last_run_at)} {formatTimestamp(policy.next_run_at)} handleToggle(policy)} />
))}
)}
{/* Create/Edit Dialog */} {editingPolicy ? 'Edit Auto-Update Policy' : 'New Auto-Update Policy'}
setFormName(e.target.value)} />
({ value: String(n.id), label: n.name }))} value={formNodeId} onValueChange={setFormNodeId} placeholder="Select node..." searchPlaceholder="Search nodes..." emptyText="No nodes found." />
({ value: s, label: s })), ]} value={formTargetId} onValueChange={setFormTargetId} placeholder={formNodeId ? "Select stack..." : "Select a node first"} searchPlaceholder="Search stacks..." emptyText="No stacks found." disabled={!formNodeId} />
{formCronPreset === 'custom' && ( setFormCron(e.target.value)} className="font-mono" /> )}

{cronDescription}

{/* Delete Confirmation */} { if (!open) setDeleteTarget(null); }}> Delete Auto-Update Policy 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); }}>
Update 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)}

)} )}
); } export default function AutoUpdatePoliciesView({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) { return ( ); }