import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } 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 { formatTimeAgo } from '@/lib/relativeTime'; import type { ImageUpdateStatus, StackUpdateInfo } from '@/types/imageUpdates'; import { useNodes } from '@/context/NodeContext'; import { useIsMobile } from '@/hooks/use-is-mobile'; import { Masthead, Kicker } from '@/components/mobile/mobile-ui'; import { ImageSourceMenu } from './ImageSourceMenu'; import type { ScheduledTask } from '@/types/scheduling'; import { SERVICE_SCOPED_UPDATE_CAPABILITY } from '@/lib/capabilities'; import { requestServiceUpdate } from '@/lib/serviceUpdate'; import { useDeployFeedback } from '@/context/DeployFeedbackContext'; 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; has_build_services?: boolean; rebuild_available?: boolean; }; build_services?: string[]; rollback_target: string | null; changelog: string | null; } function declaredServiceCount(preview: UpdatePreview | null | undefined): number { if (!preview) return 0; const names = new Set(); for (const img of preview.images) names.add(img.service); for (const name of preview.build_services ?? []) names.add(name); return names.size; } export 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 hero "ready to apply automatically" count. Manual Apply now is // schedule-independent and does not read this field. autoUpdateEnabled: boolean; // Name of the service currently applying a per-service update on this card, // or null when none is in flight. Distinct from `applying` (full-stack). applyingService: string | null; } interface NodeGroup { nodeId: number; nodeName: string; nodeType: 'local' | 'remote'; cards: StackCard[]; } interface FleetUpdateResponse { [nodeId: string]: Record; } /** * Detection-cadence status for the control instance's scanner, shown by the * readiness card: when the last registry check ran, when the next is due, and * how long the manual-recheck cooldown has left (ticking once a second). */ export function CadenceStrip({ cadence, className }: { cadence: ImageUpdateStatus | null; className?: string }) { const [remainingMs, setRemainingMs] = useState(0); useEffect(() => { setRemainingMs(cadence?.manualCooldownRemainingMs ?? 0); }, [cadence]); const cooling = remainingMs > 0; useEffect(() => { if (!cooling) return; const id = setInterval(() => setRemainingMs(prev => Math.max(0, prev - 1000)), 1000); return () => clearInterval(id); }, [cooling]); if (!cadence) return null; const lastChecked = cadence.lastCheckedAt != null ? formatTimeAgo(cadence.lastCheckedAt) : 'never'; const nextCheck = cadence.checking ? 'checking now' : cadence.nextCheckAt != null ? formatRelative(cadence.nextCheckAt) : 'not scheduled'; const cooldown = cooling ? `Recheck available in ${Math.ceil(remainingMs / 1000)}s` : 'Recheck ready'; return (
Last checked {lastChecked} Next check {nextCheck} {cooldown}
); } 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, canServiceUpdate = false, onApply, onApplyService, }: { card: StackCard; canServiceUpdate?: boolean; onApply: (stack: string, nodeId: number) => void; onApplyService?: (stack: string, nodeId: number, serviceName: string) => void; }) { const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, applyingService, autoUpdateEnabled } = card; const loading = !previewLoaded; const failed = previewLoaded && preview === null; const blocked = preview?.summary.blocked ?? false; const bump = preview?.summary.semver_bump ?? 'none'; const updatingImages = preview?.images.filter(i => i.has_update) ?? []; const updatingImageCount = updatingImages.length; // Multi-service only: count declared Compose services (image-backed and // build-only), not preview.images.length (shared tags collapse that list). const showServiceApply = canServiceUpdate && declaredServiceCount(preview) > 1 && updatingImageCount > 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}
)} {showServiceApply && (
{updatingImages.map(img => (
{img.service}
))}
)}
{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, canServiceUpdate, onApply, onApplyService, }: { group: NodeGroup; canServiceUpdate: boolean; onApply: (stack: string, nodeId: number) => void; onApplyService: (stack: string, nodeId: number, serviceName: string) => void; }) { const TypeIcon = group.nodeType === 'local' ? Monitor : Globe; const stackCount = group.cards.length; return (
{group.cards.map(card => ( ))}
); } // --- mobile ( void; onApplyService?: (stack: string, nodeId: number, serviceName: string) => void; }) { const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, applyingService, autoUpdateEnabled } = card; const failed = previewLoaded && preview === null; const blocked = preview?.summary.blocked ?? false; const bump = preview?.summary.semver_bump ?? 'none'; const updatingImages = preview?.images.filter(i => i.has_update) ?? []; const showServiceApply = canServiceUpdate && declaredServiceCount(preview) > 1 && updatingImages.length > 0; const nextRun = scheduledTask?.next_run_at ?? null; const changelog = preview?.changelog ?? 'No changelog available from the registry yet.'; const dot = changelog.indexOf('.'); const lead = dot > 0 ? changelog.slice(0, dot + 1) : ''; const rest = dot > 0 ? changelog.slice(dot + 1) : changelog; return (
stack
{stack}
{!autoUpdateEnabled && ( Auto: Off )} {previewLoaded && preview && }
{!previewLoaded ? (
Checking registry...
) : failed ? (
Preview failed. Registry may be unreachable.
) : ( <> {preview!.summary.update_kind === 'digest' ? (
{preview!.summary.current_tag} Rebuild available
) : ( )}
{preview!.summary.primary_image ?? '-'}
{lead && {lead}}{rest}
{showServiceApply && (
{updatingImages.map(img => (
{img.service}
))}
)}
{nextRun ? <>{formatClock(nextRun)} · {formatRelative(nextRun)} : (blocked ? 'Held for review' : 'No schedule')}
)}
); } function MobileNodeSection({ group, canServiceUpdate, onApply, onApplyService, }: { group: NodeGroup; canServiceUpdate: boolean; onApply: (stack: string, nodeId: number) => void; onApplyService: (stack: string, nodeId: number, serviceName: string) => void; }) { return (
{group.nodeName} {group.nodeType} {group.cards.length} {group.cards.length === 1 ? 'stack' : 'stacks'}
{group.cards.map(card => ( ))}
); } /** * Advisory for local-node stacks whose latest image-update check could not * determine status. These never appear in the card grid (which lists only * confirmed updates), so without this they would be invisible here. */ function CheckFailuresNotice({ failures }: { failures: { stack: string; reason: string | null }[] }) { if (failures.length === 0) return null; return (
    {failures.map(f => (
  • {f.stack}{f.reason ? `: ${f.reason}` : ''}
  • ))}
); } interface AutoUpdateReadinessProps { /** Notifications + more-menu cluster for the mobile masthead, rehomed from the dropped TopBar. */ headerActions?: ReactNode; } function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) { const isMobile = useIsMobile(); const { runWithLog } = useDeployFeedback(); const { nodes, nodeMeta, refreshNodeMeta } = useNodes(); const [groups, setGroups] = useState([]); const [reachableNodeCount, setReachableNodeCount] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [cadence, setCadence] = useState(null); // Local-node stacks whose latest check could not determine status. The fleet // list only shows stacks with a confirmed update, so without this a stack // whose checks all fail would silently vanish from this view. const [checkFailures, setCheckFailures] = useState<{ stack: string; reason: string | null }[]>([]); const refreshTimerRef = useRef | null>(null); // Monotonic token guards against stale setGroups from older fetches. const loadTokenRef = useRef(0); // Separate token for the cadence fetch: a slow initial /status must not // overwrite the fresher status a Recheck just loaded, and neither may set // state after unmount. const cadenceTokenRef = 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, detailRes] = await Promise.all([ apiFetch('/image-updates/fleet', { localOnly: true }), apiFetch('/scheduled-tasks?action=update', { localOnly: true }), apiFetch('/image-updates/detail', { 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); // Local-node check failures: surfaced separately because the fleet map is // boolean and the card grid only lists stacks with a confirmed update. if (detailRes.ok) { const detail = await detailRes.json() as Record; setCheckFailures( Object.entries(detail) .filter(([, info]) => info.checkStatus === 'failed') .map(([stack, info]) => ({ stack, reason: info.lastError })) .sort((a, b) => a.stack.localeCompare(b.stack)), ); } else { // Clear stale failures rather than persist them across a load, but log: // an empty advisory must not silently stand in for "detail unavailable". console.error('[AutoUpdateReadiness] /image-updates/detail failed:', detailRes.status); setCheckFailures([]); } 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, applyingService: null, 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); // Resolve service-scoped-update capability for every node in this fleet // view (not just the active one) so per-service Apply can gate on each // card's own node; skips nodes whose meta is already cached. for (const g of initialGroups) { void refreshNodeMeta(g.nodeId); } 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, refreshNodeMeta]); // Detection-cadence status for the control instance (localOnly): the readiness // list is fleet-wide, but the cadence shown by the card is this instance's own // scanner, configured in Settings. Each node runs its own scanner. const loadCadence = useCallback(async () => { const token = ++cadenceTokenRef.current; try { const res = await apiFetch('/image-updates/status', { localOnly: true }); if (!res.ok) return; const data = await res.json() as ImageUpdateStatus; // Drop the result if a newer cadence load started, or the view unmounted, // while this one was in flight. if (token === cadenceTokenRef.current) setCadence(data); } catch (e) { console.error('[AutoUpdate] failed to load image-update cadence status', e); } }, []); useEffect(() => { if (nodesSignature === '') return; loadReadiness(); void loadCadence(); return () => { // Invalidate any in-flight fetch and cancel pending refresh timers on unmount. loadTokenRef.current++; cadenceTokenRef.current++; if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); refreshTimerRef.current = null; } }; }, [loadReadiness, loadCadence, 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; // Re-seed the cadence strip so the manual-cooldown countdown reflects the // recheck we just fired. void loadCadence(); 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, loadCadence]); // Nodes that advertise service-scoped updates, resolved per node (not just // the active one) since this view spans the whole fleet. const serviceScopedNodeIds = useMemo( () => new Set( Array.from(nodeMeta.entries()) .filter(([, meta]) => meta.capabilities.includes(SERVICE_SCOPED_UPDATE_CAPABILITY)) .map(([id]) => id), ), [nodeMeta], ); const handleApplyService = useCallback(async (stack: string, nodeId: number, serviceName: string) => { 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, { applyingService: serviceName }); const loadingId = toast.loading(`Applying update to "${serviceName}" in ${stack}...`); try { await runWithLog({ stackName: stack, action: 'update', nodeId, serviceName }, async (started, ds) => { await started; const result = await requestServiceUpdate({ nodeId, stackName: stack, serviceName, mode: 'update', deploySessionId: ds, }); if (!result.ok) { toast.error(result.error); return { ok: false as const, errorMessage: result.error }; } if (result.recheckWarning) toast.info(result.recheckWarning); if (result.healthGateId && result.observing) { toast.info(`Service "${serviceName}" updated. Verifying health...`); } else { toast.success(`Service "${serviceName}" updated successfully`); } // Reload authoritative preview so summary / Apply affordances stay accurate. try { const res = await fetchForNode(`/stacks/${encodeURIComponent(stack)}/update-preview`, nodeId); if (res.ok) { const next = await res.json() as UpdatePreview; setCardField(c => c.stack === stack && c.nodeId === nodeId, { preview: next, previewLoaded: true }); } } catch { // Preview refresh is best-effort; the update itself already succeeded. } return { ok: true as const, healthGateId: result.observing ? result.healthGateId : null, recoveryId: result.recoveryId, }; }); } catch (err) { toast.error((err as Error)?.message || 'Update failed'); } finally { toast.dismiss(loadingId); setCardField(c => c.stack === stack && c.nodeId === nodeId, { applyingService: null }); } }, [runWithLog]); 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; if (isMobile) { return (
0} meta={total > 0 ? `${ready} ready · ${total - ready} in review` : 'all stacks current'} right={headerActions} />
{showPartialBanner && (
{reachableNodeCount} of {onlineNodeCount} nodes reachable. Unreachable nodes are not shown.
)} {loading && groups.length === 0 ? (
Loading readiness...
) : groups.length === 0 ? (
) : ( groups.map(group => ( )) )}
); } 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(props: AutoUpdateReadinessProps = {}) { return ; }