import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { RefreshCw, Shield, AlertTriangle, ShieldAlert, CircleSlash, Clock, Play, CalendarClock, Monitor, Globe } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import type { ScheduledTask } from '@/types/scheduling'; type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown'; interface UpdatePreviewImage { service: string; image: string; current_tag: string; next_tag: string | null; has_update: boolean; semver_bump: SemverBump; } type UpdateKind = 'tag' | 'digest' | 'none'; interface UpdatePreview { stack_name: string; images: UpdatePreviewImage[]; summary: { has_update: boolean; primary_image: string | null; current_tag: string | null; next_tag: string | null; semver_bump: SemverBump; update_kind: UpdateKind; blocked: boolean; blocked_reason: string | null; }; rollback_target: string | null; changelog: string | null; } interface StackCard { stack: string; nodeId: number; preview: UpdatePreview | null; previewLoaded: boolean; scheduledTask: ScheduledTask | null; applying: boolean; // True when at least one enabled action='update' scheduled task covers this // stack on this node (per-stack row or fleet row). Drives the Auto: Off pill // and the Apply now button's disabled state. autoUpdateEnabled: boolean; } interface NodeGroup { nodeId: number; nodeName: string; nodeType: 'local' | 'remote'; cards: StackCard[]; } interface FleetUpdateResponse { [nodeId: string]: Record; } function formatRelative(ts: number | null): string { if (ts == null) return ''; const delta = ts - Date.now(); if (delta <= 0) return 'due now'; const mins = Math.round(delta / 60_000); if (mins < 60) return `in ${mins}m`; const hours = Math.floor(mins / 60); const remMins = mins % 60; if (hours < 24) return remMins > 0 ? `in ${hours}h ${remMins}m` : `in ${hours}h`; const days = Math.floor(hours / 24); const remHours = hours % 24; return remHours > 0 ? `in ${days}d ${remHours}h` : `in ${days}d`; } function formatClock(ts: number | null): string { if (ts == null) return ''; return new Date(ts).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit', }); } function RiskBadge({ bump, blocked }: { bump: SemverBump; blocked: boolean }) { if (blocked || bump === 'major') { return ( Blocked · major ); } if (bump === 'minor') { return ( Review · minor ); } if (bump === 'patch') { return ( Safe · patch ); } if (bump === 'unknown') { return ( Digest rebuild ); } return ( None ); } function VersionDiff({ current, next }: { current: string | null; next: string | null }) { if (!current) return null; const changed = next && next !== current; return (
{current} {next ?? current}
); } function StackReadinessCard({ card, onApply, }: { card: StackCard; onApply: (stack: string, nodeId: number) => void; }) { const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, autoUpdateEnabled } = card; const loading = !previewLoaded; const failed = previewLoaded && preview === null; const blocked = preview?.summary.blocked ?? false; const bump = preview?.summary.semver_bump ?? 'none'; const updatingImageCount = preview?.images.filter(i => i.has_update).length ?? 0; const nextRun = scheduledTask?.next_run_at ?? null; return (
Stack {stack}
{!autoUpdateEnabled && ( Auto: Off )} {previewLoaded && preview && }
{loading ? (
Checking registry...
) : failed ? (
Preview failed. Registry may be unreachable.
) : ( (() => { const p = preview!; const blockedReason = p.summary.blocked_reason; return ( <> {p.summary.update_kind === 'digest' ? (
{p.summary.current_tag} Rebuild available
) : ( )}
{p.summary.primary_image ?? '-'} {updatingImageCount > 1 && ( · {updatingImageCount} services )}
{p.changelog ?? 'No changelog available from the registry yet.'}
{blocked && blockedReason && (
{blockedReason}
)}
{nextRun ? ( <>
); })() )}
); } function ReadinessHero({ total, ready, nodeCount, refreshing, onRefresh, }: { total: number; ready: number; nodeCount: number; refreshing: boolean; onRefresh: () => void; }) { const headline = total === 0 ? 'Everything is up to date' : total === 1 ? '1 update pending' : `${total} updates pending`; const acrossNodes = nodeCount > 1 ? ` across ${nodeCount} nodes` : nodeCount === 1 ? ' across 1 node' : ''; return (
Fleet readiness {headline} {total > 0 && ( {ready} of {total} ready to apply automatically{acrossNodes} {total - ready > 0 ? ` · ${total - ready} need a schedule or review` : ''} )}
{total > 0 && (
{ready} / {total}
Ready
)}
); } function NodeGroupSection({ group, onApply, }: { group: NodeGroup; onApply: (stack: string, nodeId: number) => void; }) { const TypeIcon = group.nodeType === 'local' ? Monitor : Globe; const stackCount = group.cards.length; return (
{group.cards.map(card => ( ))}
); } function AutoUpdateReadinessContent() { const { nodes } = useNodes(); const [groups, setGroups] = useState([]); const [reachableNodeCount, setReachableNodeCount] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const refreshTimerRef = useRef | null>(null); // Monotonic token guards against stale setGroups from older fetches. const loadTokenRef = useRef(0); // Holds the latest nodes array so loadReadiness can reference it without // re-firing every time NodeContext rebuilds the array on a meta refresh. const nodesRef = useRef(nodes); nodesRef.current = nodes; // Stable signature: only changes when membership or node identity actually // changes, not when NodeContext reissues the same logical list. const nodesSignature = useMemo( () => nodes.map(n => `${n.id}:${n.type}:${n.status}`).sort().join('|'), [nodes], ); const localNodeId = useMemo(() => nodes.find(n => n.type === 'local')?.id ?? null, [nodes]); const onlineNodeCount = useMemo(() => nodes.filter(n => n.status === 'online').length, [nodes]); const loadReadiness = useCallback(async () => { const token = ++loadTokenRef.current; setLoading(true); try { const [statusRes, tasksRes] = await Promise.all([ apiFetch('/image-updates/fleet', { localOnly: true }), apiFetch('/scheduled-tasks?action=update', { localOnly: true }), ]); if (token !== loadTokenRef.current) return; if (!statusRes.ok) { throw new Error('Failed to load fleet update status'); } const fleetStatus = await statusRes.json() as FleetUpdateResponse; setReachableNodeCount(Object.keys(fleetStatus).length); const tasks: ScheduledTask[] = tasksRes.ok ? await tasksRes.json() : []; // A stack is "covered" by an enabled action='update' row when either // a per-stack row targets it or a fleet row targets its node. We pick // the earliest next-run covering task so the readiness card renders // the next-run time accurately for both shapes. const taskByNodeStack = new Map(); const fleetTaskByNode = new Map(); for (const t of tasks) { if (!t.enabled) continue; // The fetch URL filters on action=update; this guard makes the // coverage check robust against a future regression there. if (t.action !== 'update') continue; const taskNodeId = t.node_id ?? localNodeId; if (taskNodeId == null) continue; if (t.target_type === 'fleet') { const existing = fleetTaskByNode.get(taskNodeId); if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) { fleetTaskByNode.set(taskNodeId, t); } } else if (t.target_type === 'stack' && t.target_id) { const key = `${taskNodeId}::${t.target_id}`; const existing = taskByNodeStack.get(key); if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) { taskByNodeStack.set(key, t); } } } const flatPairs: { nodeId: number; stack: string }[] = []; const initialGroups: NodeGroup[] = []; const currentNodes = nodesRef.current; for (const [nodeIdStr, stackMap] of Object.entries(fleetStatus)) { const nodeId = Number(nodeIdStr); const node = currentNodes.find(n => n.id === nodeId); if (!node) continue; const stacks = Object.entries(stackMap) .filter(([, hasUpdate]) => hasUpdate) .map(([stack]) => stack) .sort(); if (stacks.length === 0) continue; const cards: StackCard[] = stacks.map(stack => { flatPairs.push({ nodeId, stack }); const stackTask = taskByNodeStack.get(`${nodeId}::${stack}`) ?? null; const fleetTask = fleetTaskByNode.get(nodeId) ?? null; // Prefer whichever covering task fires next. // Earliest next-run wins; on a tie, the per-stack row beats the // fleet row so the user sees the more specific schedule. const scheduledTask = stackTask && fleetTask ? ((stackTask.next_run_at ?? Infinity) <= (fleetTask.next_run_at ?? Infinity) ? stackTask : fleetTask) : (stackTask ?? fleetTask); return { stack, nodeId, preview: null, previewLoaded: false, scheduledTask, applying: false, autoUpdateEnabled: scheduledTask !== null, }; }); initialGroups.push({ nodeId, nodeName: node.name, nodeType: node.type, cards, }); } initialGroups.sort((a, b) => { if (a.nodeType !== b.nodeType) return a.nodeType === 'local' ? -1 : 1; return a.nodeName.localeCompare(b.nodeName); }); if (token !== loadTokenRef.current) return; setGroups(initialGroups); const previews = await Promise.all( flatPairs.map(async ({ nodeId, stack }) => { try { const res = await fetchForNode(`/stacks/${encodeURIComponent(stack)}/update-preview`, nodeId); if (!res.ok) return null; return await res.json() as UpdatePreview; } catch { return null; } }), ); if (token !== loadTokenRef.current) return; const previewByKey = new Map(); flatPairs.forEach((pair, idx) => { previewByKey.set(`${pair.nodeId}::${pair.stack}`, previews[idx]); }); setGroups(initialGroups.map(g => ({ ...g, cards: g.cards.map(c => ({ ...c, preview: previewByKey.get(`${c.nodeId}::${c.stack}`) ?? null, previewLoaded: true, })), }))); } catch (err) { if (token !== loadTokenRef.current) return; toast.error((err as Error)?.message || 'Failed to load readiness'); } finally { if (token === loadTokenRef.current) setLoading(false); } }, [localNodeId]); useEffect(() => { if (nodesSignature === '') return; loadReadiness(); return () => { // Invalidate any in-flight fetch and cancel pending refresh timers on unmount. loadTokenRef.current++; if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); refreshTimerRef.current = null; } }; }, [loadReadiness, nodesSignature]); const handleRefresh = useCallback(async () => { setRefreshing(true); try { const res = await apiFetch('/image-updates/fleet/refresh', { method: 'POST', localOnly: true }); if (!res.ok) { toast.error('Failed to trigger refresh'); return; } const data = await res.json() as { triggered: number[]; rateLimited: number[]; failed: number[] }; const tCount = data.triggered.length; const rCount = data.rateLimited.length; const fCount = data.failed.length; if (tCount > 0) { toast.success(`Rechecking ${tCount} ${tCount === 1 ? 'node' : 'nodes'}...`); } if (rCount > 0) { toast.warning(`${rCount} ${rCount === 1 ? 'node is' : 'nodes are'} rate-limited; try again shortly`); } if (fCount > 0) { toast.error(`${fCount} ${fCount === 1 ? 'node' : 'nodes'} failed to refresh`); } if (tCount === 0 && rCount === 0 && fCount === 0) { toast.info('No reachable nodes to refresh'); return; } if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); refreshTimerRef.current = setTimeout(() => { refreshTimerRef.current = null; loadReadiness(); }, 2500); } catch (err) { toast.error((err as Error)?.message || 'Failed to trigger refresh'); } finally { setRefreshing(false); } }, [loadReadiness]); const handleApply = useCallback(async (stack: string, nodeId: number) => { const setCardField = (predicate: (c: StackCard) => boolean, patch: Partial) => setGroups(prev => prev.map(g => ({ ...g, cards: g.cards.map(c => predicate(c) ? { ...c, ...patch } : c), }))); setCardField(c => c.stack === stack && c.nodeId === nodeId, { applying: true }); const loadingId = toast.loading(`Applying update to ${stack}...`); try { const res = await fetchForNode( `/stacks/${encodeURIComponent(stack)}/update`, nodeId, { method: 'POST' }, ); if (!res.ok) { const data = await res.json().catch(() => ({ error: 'Update failed' })); throw new Error(data.error ?? 'Update failed'); } toast.success(`${stack} updated successfully`); setGroups(prev => prev .map(g => g.nodeId === nodeId ? { ...g, cards: g.cards.filter(c => c.stack !== stack) } : g) .filter(g => g.cards.length > 0)); } catch (err) { toast.error((err as Error)?.message || 'Update failed'); setCardField(c => c.stack === stack && c.nodeId === nodeId, { applying: false }); } finally { toast.dismiss(loadingId); } }, []); const flatCards = useMemo(() => groups.flatMap(g => g.cards), [groups]); const { total, ready } = useMemo(() => { const t = flatCards.length; // "Ready" means a schedule covers the stack, the preview loaded without // error, and no major-bump blocked it. Without a covering schedule the // stack cannot apply automatically regardless of preview state. const r = flatCards.filter(c => c.autoUpdateEnabled && c.previewLoaded && c.preview !== null && !c.preview.summary.blocked, ).length; return { total: t, ready: r }; }, [flatCards]); const showPartialBanner = reachableNodeCount != null && onlineNodeCount > 0 && reachableNodeCount < onlineNodeCount; return (
{showPartialBanner && (
{reachableNodeCount} of {onlineNodeCount} nodes reachable. Unreachable nodes are not shown.
)} {loading && groups.length === 0 ? (
Loading readiness...
) : groups.length === 0 ? (
) : (
{groups.map(group => ( ))}
)}
); } export default function AutoUpdateReadinessView() { return ; }