feat(fleet): add remote node update management (#353)

Add the ability to check for outdated nodes and trigger over-the-air
updates from Fleet View. Nodes self-update by pulling the latest Docker
image and recreating their container via the "last breath" pattern.

Backend:
- SelfUpdateService: self-container identification via HOSTNAME + Docker
  Compose labels, triggers pull + force-recreate
- CapabilityRegistry: runtime capability disabling via disableCapability()
- POST /api/system/update (202 + deferred self-update)
- GET /api/fleet/update-status (version comparison across fleet)
- POST /api/fleet/nodes/:nodeId/update (single node)
- POST /api/fleet/update-all (bulk remote update)
- In-memory update tracker with 5-min timeout

Frontend:
- Node Updates modal with summary stats, search filter, table layout,
  per-node Update buttons, and bulk Update All
- Version badges and update-available indicators on node cards
- ReconnectingOverlay for local node updates (polls /api/health)
- 5s fast-poll when any node is actively updating
- UpdateStatusBadge shared component for consistent badge rendering

Requires Skipper (Pro) tier. Nodes must be deployed via Docker Compose
with Docker socket access.
This commit is contained in:
Anso
2026-04-03 01:39:22 -04:00
committed by GitHub
parent d670984635
commit 87b5908288
13 changed files with 1039 additions and 21 deletions
+466 -18
View File
@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight,
Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle, Box, Activity,
Play, Square, RotateCcw, ExternalLink, Camera,
Play, Square, RotateCcw, ExternalLink, Camera, Download, Loader2, Check,
CircleCheck, CircleAlert, Globe, Monitor,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -11,6 +12,13 @@ import { Input } from '@/components/ui/input';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { springs } from '@/lib/motion';
import { apiFetch } from '@/lib/api';
@@ -54,6 +62,16 @@ interface StackContainer {
Status?: string;
}
interface NodeUpdateStatus {
nodeId: number;
name: string;
type: 'local' | 'remote';
version: string | null;
latestVersion: string;
updateAvailable: boolean;
updateStatus: 'updating' | 'completed' | 'timeout' | 'failed' | null;
}
type SortField = 'name' | 'cpu' | 'memory' | 'containers' | 'status';
type SortDir = 'asc' | 'desc';
type FilterStatus = 'all' | 'online' | 'offline';
@@ -268,7 +286,76 @@ function StackSection({ stackName, nodeId, onNavigate, labelMap }: {
);
}
function NodeCard({ node, onNavigate, labelMap }: { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void; labelMap?: Record<string, StackLabel[]> }) {
function UpdateStatusBadge({ status }: { status: NodeUpdateStatus['updateStatus'] }) {
if (status === 'updating') return (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-info/15 text-info border-info/30 shrink-0">
<Loader2 className="w-2.5 h-2.5 mr-0.5 animate-spin" /> Updating
</Badge>
);
if (status === 'completed') return (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-success-muted text-success border-success/30 shrink-0">
<Check className="w-2.5 h-2.5 mr-0.5" /> Updated
</Badge>
);
if (status === 'timeout') return (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4 shrink-0">Timed out</Badge>
);
if (status === 'failed') return (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4 shrink-0">Failed</Badge>
);
return null;
}
function ReconnectingOverlay() {
const [elapsed, setElapsed] = useState(0);
const timedOut = elapsed >= 300; // 5 minutes
useEffect(() => {
const timer = setInterval(() => setElapsed(s => s + 1), 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (timedOut) return;
const poll = setInterval(async () => {
try {
const res = await fetch('/api/health');
if (res.ok) window.location.reload();
} catch { /* still down */ }
}, 3000);
return () => clearInterval(poll);
}, [timedOut]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-[10px] backdrop-saturate-[1.15]">
<div className="text-center space-y-4">
{timedOut ? (
<>
<AlertTriangle className="w-10 h-10 text-warning mx-auto" strokeWidth={1.5} />
<h2 className="text-lg font-medium">Update timed out</h2>
<p className="text-sm text-muted-foreground max-w-sm">
The server has not come back within 5 minutes. Check the Docker host directly.
</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Try Reloading
</Button>
</>
) : (
<>
<Loader2 className="w-10 h-10 text-muted-foreground animate-spin mx-auto" strokeWidth={1.5} />
<h2 className="text-lg font-medium">Updating Sencho...</h2>
<p className="text-sm text-muted-foreground max-w-sm">
The server is pulling the latest image and restarting. This page will reload automatically.
</p>
<p className="text-xs text-muted-foreground tabular-nums">{elapsed}s elapsed</p>
</>
)}
</div>
</div>
);
}
function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId }: { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void; labelMap?: Record<string, StackLabel[]>; updateStatus?: NodeUpdateStatus; onUpdate?: (nodeId: number) => void; updatingNodeId?: number | null }) {
const { isPro } = useLicense();
const [expanded, setExpanded] = useState(false);
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
@@ -312,19 +399,30 @@ function NodeCard({ node, onNavigate, labelMap }: { node: FleetNode; onNavigate:
</div>
<div className="min-w-0">
<h3 className="text-sm font-medium truncate">{node.name}</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant={isOnline ? 'default' : 'secondary'} className="text-[10px] px-1.5 py-0 h-4">
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
<Badge variant={isOnline ? 'default' : 'secondary'} className="text-[10px] px-1.5 py-0 h-4 shrink-0">
{isOnline ? (
<><Wifi className="w-2.5 h-2.5 mr-0.5" /> Online</>
) : (
<><WifiOff className="w-2.5 h-2.5 mr-0.5" /> Offline</>
)}
</Badge>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
{node.type}
</Badge>
{updateStatus?.version && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 font-mono tabular-nums shrink-0">
v{updateStatus.version}
</Badge>
)}
{updateStatus?.updateStatus && <UpdateStatusBadge status={updateStatus.updateStatus} />}
{updateStatus?.updateAvailable && !updateStatus.updateStatus && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-warning/15 text-warning border-warning/30 shrink-0">
Update available
</Badge>
)}
{isOnline && isCritical(node) && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4">
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" /> Critical
</Badge>
)}
@@ -386,6 +484,25 @@ function NodeCard({ node, onNavigate, labelMap }: { node: FleetNode; onNavigate:
</div>
)}
{/* Update button */}
{isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && onUpdate && (
<div className="mt-3 pt-3 border-t border-border/50">
<Button
variant="outline"
size="sm"
className="w-full h-7 text-xs"
onClick={() => onUpdate(node.id)}
disabled={updatingNodeId === node.id}
>
{updatingNodeId === node.id ? (
<><Loader2 className="w-3 h-3 mr-1.5 animate-spin" />Triggering...</>
) : (
<><Download className="w-3 h-3 mr-1.5" strokeWidth={1.5} />Update to v{updateStatus.latestVersion}</>
)}
</Button>
</div>
)}
{/* Offline placeholder */}
{!isOnline && (
<div className="flex items-center justify-center py-6 text-muted-foreground text-sm">
@@ -454,6 +571,15 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<string, StackLabel[]>>({});
const [labelFilters, setLabelFilters] = useState<Set<number>>(new Set());
const { isPro } = useLicense();
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
const [reconnecting, setReconnecting] = useState(false);
const [localUpdateConfirm, setLocalUpdateConfirm] = useState<number | null>(null);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [checkingUpdates, setCheckingUpdates] = useState(false);
const [modalSearch, setModalSearch] = useState('');
const updateStatusesRef = useRef(updateStatuses);
updateStatusesRef.current = updateStatuses;
const updatePrefs = useCallback((update: Partial<FleetPreferences>) => {
setPrefs(prev => {
@@ -492,10 +618,90 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
}
}, [isPro]);
const fetchUpdateStatus = useCallback(async () => {
if (!isPro) return;
try {
const res = await apiFetch('/fleet/update-status', { localOnly: true });
if (res.ok) {
const data = await res.json();
const next: NodeUpdateStatus[] = data.nodes ?? [];
setUpdateStatuses(prev =>
JSON.stringify(prev) === JSON.stringify(next) ? prev : next
);
}
} catch { /* non-critical */ }
}, [isPro]);
const triggerNodeUpdate = useCallback(async (nodeId: number) => {
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
if (status?.type === 'local') {
setLocalUpdateConfirm(nodeId);
return;
}
setUpdatingNodeId(nodeId);
try {
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true });
if (res.ok) {
toast.success(`Update initiated on ${status?.name ?? 'node'}.`);
fetchUpdateStatus();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger update.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setUpdatingNodeId(null);
}
}, [fetchUpdateStatus]);
const confirmLocalUpdate = useCallback(async () => {
const nodeId = localUpdateConfirm;
setLocalUpdateConfirm(null);
if (!nodeId) return;
setUpdatingNodeId(nodeId);
try {
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true });
if (res.ok) {
setReconnecting(true);
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger local update.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setUpdatingNodeId(null);
}
}, [localUpdateConfirm]);
const triggerUpdateAll = useCallback(async () => {
try {
const res = await apiFetch('/fleet/update-all', { method: 'POST', localOnly: true });
if (res.ok) {
const data = await res.json();
if (data.updating?.length > 0) {
toast.success(`Update initiated on ${data.updating.length} node${data.updating.length > 1 ? 's' : ''}.`);
} else {
toast.success('All nodes are up to date.');
}
fetchUpdateStatus();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger fleet update.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
}
}, [fetchUpdateStatus]);
useEffect(() => {
fetchOverview();
fetchLabels();
}, [fetchOverview, fetchLabels]);
fetchUpdateStatus();
}, [fetchOverview, fetchLabels, fetchUpdateStatus]);
// Pro: auto-refresh every 30s
useEffect(() => {
@@ -504,6 +710,22 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
return () => clearInterval(interval);
}, [isPro, fetchOverview]);
// Fast poll (5s) when any node is actively updating — uses ref to avoid interval thrashing
const hasUpdatingRef = useRef(false);
useEffect(() => {
hasUpdatingRef.current = updateStatuses.some(s => s.updateStatus === 'updating');
}, [updateStatuses]);
useEffect(() => {
const id = setInterval(() => {
if (hasUpdatingRef.current) {
fetchUpdateStatus();
fetchOverview();
}
}, 5000);
return () => clearInterval(id);
}, [fetchUpdateStatus, fetchOverview]);
// --- Computed values ---
const onlineNodes = useMemo(() => nodes.filter(n => n.status === 'online'), [nodes]);
@@ -523,6 +745,16 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const totalMemUsed = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.used ?? 0), 0);
const totalMemTotal = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.total ?? 0), 0);
const updatableRemoteCount = useMemo(
() => updateStatuses.filter(s => s.updateAvailable && !s.updateStatus && s.type === 'remote').length,
[updateStatuses]
);
const updateStatusMap = useMemo(
() => new Map(updateStatuses.map(s => [s.nodeId, s])),
[updateStatuses]
);
// --- Filtering & Sorting (Pro) ---
const processedNodes = useMemo(() => {
@@ -596,16 +828,34 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{loading ? 'Loading...' : `${onlineCount} of ${nodes.length} nodes online · ${totalContainers} containers · ${totalStacks} stacks`}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => fetchOverview(true)}
disabled={refreshing}
className="gap-2"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
<div className="flex items-center gap-2">
{isPro && (
<Button
variant="outline"
size="sm"
onClick={async () => {
setShowUpdateModal(true);
setCheckingUpdates(true);
await fetchUpdateStatus();
setCheckingUpdates(false);
}}
className="gap-2"
>
<Search className="w-4 h-4" />
Check Updates
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => fetchOverview(true)}
disabled={refreshing}
className="gap-2"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
<Tabs defaultValue="overview">
@@ -804,6 +1054,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
node={node}
onNavigate={onNavigateToNode}
labelMap={fleetStackLabelMap}
updateStatus={updateStatusMap.get(node.id)}
onUpdate={isPro ? triggerNodeUpdate : undefined}
updatingNodeId={updatingNodeId}
/>
))}
</div>
@@ -863,6 +1116,201 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</TabsContent>
)}
</Tabs>
{/* Reconnecting overlay — shown when local node is updating */}
{reconnecting && <ReconnectingOverlay />}
{/* Node Updates modal */}
<Dialog open={showUpdateModal} onOpenChange={(open) => { setShowUpdateModal(open); if (!open) setModalSearch(''); }}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Node Updates</DialogTitle>
<DialogDescription className="sr-only">Check and apply updates across your fleet nodes.</DialogDescription>
</DialogHeader>
{checkingUpdates ? (
<div className="flex items-center justify-center py-16 text-muted-foreground text-sm gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Checking for updates...
</div>
) : updateStatuses.length === 0 ? (
<div className="flex items-center justify-center py-16 text-muted-foreground text-sm">
No nodes found.
</div>
) : (() => {
const upToDate = updateStatuses.filter(s => !s.updateAvailable && !s.updateStatus).length;
const available = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus).length;
const updating = updateStatuses.filter(s => s.updateStatus === 'updating').length;
const failed = updateStatuses.filter(s => s.updateStatus === 'failed' || s.updateStatus === 'timeout').length;
const q = modalSearch.toLowerCase();
const filtered = q ? updateStatuses.filter(s => s.name.toLowerCase().includes(q) || s.type.includes(q)) : updateStatuses;
return (
<>
{/* Summary stats */}
<div className="grid grid-cols-4 gap-2">
<div className="rounded-lg border border-card-border bg-card px-3 py-2 text-center">
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{upToDate}</div>
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
<CircleCheck className="w-3 h-3 text-success" strokeWidth={1.5} /> Up to date
</div>
</div>
<div className="rounded-lg border border-card-border bg-card px-3 py-2 text-center">
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{available}</div>
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
<CircleAlert className="w-3 h-3 text-warning" strokeWidth={1.5} /> Available
</div>
</div>
<div className="rounded-lg border border-card-border bg-card px-3 py-2 text-center">
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{updating}</div>
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
<Loader2 className="w-3 h-3 text-info" strokeWidth={1.5} /> Updating
</div>
</div>
<div className="rounded-lg border border-card-border bg-card px-3 py-2 text-center">
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{failed}</div>
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
<AlertTriangle className="w-3 h-3 text-destructive/70" strokeWidth={1.5} /> Failed
</div>
</div>
</div>
{/* Search + gateway version */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Filter nodes..."
value={modalSearch}
onChange={e => setModalSearch(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="text-[11px] text-muted-foreground shrink-0">
Gateway: <span className="font-mono tabular-nums text-foreground">v{updateStatuses[0]?.latestVersion}</span>
</div>
</div>
{/* Table header */}
<div className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 px-3 text-[10px] text-muted-foreground uppercase tracking-wider">
<span>Node</span>
<span>Type</span>
<span>Current</span>
<span>Latest</span>
<span className="text-right">Status</span>
</div>
{/* Node list */}
<div className="flex-1 overflow-y-auto space-y-1 min-h-0 max-h-[40vh] -mx-1 px-1">
{filtered.map(s => (
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 items-center rounded-lg border border-card-border bg-card px-3 py-2">
{/* Node name */}
<div className="flex items-center gap-2.5 min-w-0">
<div className={`flex items-center justify-center w-6 h-6 rounded-md shrink-0 ${s.updateAvailable && !s.updateStatus ? 'bg-warning/10' : 'bg-muted'}`}>
{s.type === 'local'
? <Monitor className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
: <Globe className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
}
</div>
<span className="text-sm font-medium truncate">{s.name}</span>
</div>
{/* Type */}
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 w-fit">
{s.type}
</Badge>
{/* Current version */}
<span className="text-xs font-mono tabular-nums text-muted-foreground">
{s.version ? `v${s.version}` : <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
</span>
{/* Latest version */}
<span className="text-xs font-mono tabular-nums">
v{s.latestVersion}
</span>
{/* Status / Action */}
<div className="flex justify-end">
{s.updateStatus && <UpdateStatusBadge status={s.updateStatus} />}
{!s.updateStatus && !s.updateAvailable && (
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-success-muted text-success border-success/30">
<Check className="w-2.5 h-2.5 mr-0.5" /> Up to date
</Badge>
)}
{s.updateAvailable && !s.updateStatus && (
<Button
variant="outline"
size="sm"
className="h-6 text-[11px] px-2.5"
onClick={() => triggerNodeUpdate(s.nodeId)}
disabled={updatingNodeId === s.nodeId}
>
{updatingNodeId === s.nodeId ? (
<><Loader2 className="w-3 h-3 mr-1 animate-spin" />Updating</>
) : (
<><Download className="w-3 h-3 mr-1" strokeWidth={1.5} />Update</>
)}
</Button>
)}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
No nodes match &ldquo;{modalSearch}&rdquo;
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-2 border-t border-border/50">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={async () => { setCheckingUpdates(true); await fetchUpdateStatus(); setCheckingUpdates(false); }}
>
<RefreshCw className="w-3 h-3 mr-1.5" strokeWidth={1.5} />
Recheck
</Button>
{updatableRemoteCount > 0 && (
<Button
variant="outline"
size="sm"
onClick={triggerUpdateAll}
className="h-7 gap-1.5"
>
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
Update All ({updatableRemoteCount})
</Button>
)}
</div>
</>
);
})()}
</DialogContent>
</Dialog>
{/* Confirm dialog for local node update */}
<AlertDialog open={localUpdateConfirm !== null} onOpenChange={(open) => { if (!open) setLocalUpdateConfirm(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Update local node?</AlertDialogTitle>
<AlertDialogDescription>
This will pull the latest Sencho image and restart the server. The dashboard will be
briefly disconnected and will automatically reconnect when the update completes.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmLocalUpdate}>
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Update & Restart
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}