mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
3d69746eee
The "Updating Sencho..." overlay used to dismiss prematurely while the image pull was still running, after which the local node card would get stuck in "updating" and eventually surface a generic "Timed Out" error while the container remained on the old version. Three root causes are addressed: 1. The image pull was synchronous (`execFileSync`), which blocked the Node event loop. The overlay's health probe saw the server come back the moment the pull finished and reloaded the page, even though the container had not restarted yet. The pull is now async via `promisify(execFile)`, so /api/health and /api/fleet/update-status keep serving throughout. 2. The overlay reloaded on the first 200 from /api/health regardless of whether the underlying process had actually restarted. /api/health now exposes the gateway boot timestamp, and the overlay captures it pre-update and only reloads when it observes a different value. A wasOffline-then-online fallback handles the case where the pre-update fetch failed. 3. Helper container spawn errors from `docker run` were silently discarded, so a failed compose recreate never surfaced anywhere. Errors are now captured into `lastUpdateError` via the execFile callback and surfaced through the existing /api/fleet/update-status error path. A 3-minute early-fail heuristic on the local node block surfaces a clear failure message when the helper fails silently, instead of waiting the full 5-minute timeout for an unknown failure.
1446 lines
73 KiB
TypeScript
1446 lines
73 KiB
TypeScript
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, Download, Loader2, Check,
|
|
CircleCheck, CircleAlert, Globe, Monitor, X,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
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';
|
|
import { useLicense } from '@/context/LicenseContext';
|
|
import { PaidGate } from './PaidGate';
|
|
import FleetSnapshots from './FleetSnapshots';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { LabelDot, type Label as StackLabel } from './LabelPill';
|
|
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
|
import { formatVersion } from '@/lib/version';
|
|
import { CursorProvider, Cursor, CursorFollow, CursorContainer } from '@/components/animate-ui/primitives/animate/cursor';
|
|
|
|
// --- Types ---
|
|
|
|
interface FleetNodeStats {
|
|
active: number;
|
|
managed: number;
|
|
unmanaged: number;
|
|
exited: number;
|
|
total: number;
|
|
}
|
|
|
|
interface FleetNodeSystemStats {
|
|
cpu: { usage: string; cores: number };
|
|
memory: { total: number; used: number; free: number; usagePercent: string };
|
|
disk: { total: number; used: number; free: number; usagePercent: string } | null;
|
|
}
|
|
|
|
interface FleetNode {
|
|
id: number;
|
|
name: string;
|
|
type: 'local' | 'remote';
|
|
status: 'online' | 'offline' | 'unknown';
|
|
stats: FleetNodeStats | null;
|
|
systemStats: FleetNodeSystemStats | null;
|
|
stacks: string[] | null;
|
|
}
|
|
|
|
interface StackContainer {
|
|
Id?: string;
|
|
Names?: string[];
|
|
Image?: string;
|
|
State?: string;
|
|
Status?: string;
|
|
}
|
|
|
|
interface NodeUpdateStatus {
|
|
nodeId: number;
|
|
name: string;
|
|
type: 'local' | 'remote';
|
|
version: string | null;
|
|
latestVersion: string | null;
|
|
updateAvailable: boolean;
|
|
updateStatus: 'updating' | 'completed' | 'timeout' | 'failed' | null;
|
|
error?: string | null;
|
|
}
|
|
|
|
type SortField = 'name' | 'cpu' | 'memory' | 'containers' | 'status';
|
|
type SortDir = 'asc' | 'desc';
|
|
type FilterStatus = 'all' | 'online' | 'offline';
|
|
type FilterType = 'all' | 'local' | 'remote';
|
|
|
|
interface FleetPreferences {
|
|
sortBy: SortField;
|
|
sortDir: SortDir;
|
|
filterStatus: FilterStatus;
|
|
filterType: FilterType;
|
|
filterCritical: boolean;
|
|
}
|
|
|
|
const PREFS_KEY = 'sencho-fleet-preferences';
|
|
|
|
function loadPreferences(): FleetPreferences {
|
|
try {
|
|
const stored = localStorage.getItem(PREFS_KEY);
|
|
if (stored) return JSON.parse(stored) as FleetPreferences;
|
|
} catch { /* use defaults */ }
|
|
return { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false };
|
|
}
|
|
|
|
function savePreferences(prefs: FleetPreferences) {
|
|
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
|
|
}
|
|
|
|
// --- Utilities ---
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function getNodeCpu(node: FleetNode): number {
|
|
return node.systemStats ? parseFloat(node.systemStats.cpu.usage) : 0;
|
|
}
|
|
|
|
function getNodeMem(node: FleetNode): number {
|
|
return node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0;
|
|
}
|
|
|
|
function getNodeDisk(node: FleetNode): number {
|
|
return node.systemStats?.disk ? parseFloat(node.systemStats.disk.usagePercent) : 0;
|
|
}
|
|
|
|
function isCritical(node: FleetNode): boolean {
|
|
return getNodeCpu(node) > 90 || getNodeDisk(node) > 90;
|
|
}
|
|
|
|
function containerName(c: StackContainer): string {
|
|
if (c.Names && c.Names.length > 0) {
|
|
return c.Names[0].replace(/^\//, '');
|
|
}
|
|
return c.Id?.slice(0, 12) ?? 'unknown';
|
|
}
|
|
|
|
// --- Sub-Components ---
|
|
|
|
function UsageBar({ percent, color }: { percent: number; color: string }) {
|
|
return (
|
|
<div className="h-1.5 w-full bg-muted rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-500 ${color}`}
|
|
style={{ width: `${Math.min(100, percent)}%` }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({ icon: Icon, label, value, sub, alert }: {
|
|
icon: React.ElementType;
|
|
label: string;
|
|
value: string;
|
|
sub?: string;
|
|
alert?: boolean;
|
|
}) {
|
|
return (
|
|
<div className={`rounded-lg border bg-card p-4 ${alert ? 'border-red-500/30 bg-red-500/5' : ''}`}>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Icon className={`w-4 h-4 ${alert ? 'text-red-500' : 'text-stat-icon'}`} />
|
|
<span className="text-xs text-stat-title">{label}</span>
|
|
</div>
|
|
<div className={`text-2xl font-medium tabular-nums tracking-tight ${alert ? 'text-destructive/70' : 'text-stat-value'}`}>{value}</div>
|
|
{sub && <p className="text-xs text-stat-subtitle mt-1">{sub}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ContainerRow({ container, nodeId, onNavigate }: {
|
|
container: StackContainer;
|
|
nodeId: number;
|
|
onNavigate: (nodeId: number) => void;
|
|
}) {
|
|
const name = containerName(container);
|
|
const state = container.State?.toLowerCase() ?? 'unknown';
|
|
const image = container.Image;
|
|
const status = container.Status ?? '';
|
|
|
|
const stateColor = state === 'running' ? 'bg-success' :
|
|
state === 'restarting' ? 'bg-warning' : 'bg-red-500';
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group">
|
|
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${stateColor}`} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-medium truncate">{name}</span>
|
|
<Badge variant="outline" className="text-[9px] px-1 py-0 h-3.5 shrink-0">{state}</Badge>
|
|
</div>
|
|
{(image || status) && (
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
{image && <span className="text-[10px] text-muted-foreground truncate">{image}</span>}
|
|
{status && <span className="text-[10px] text-muted-foreground shrink-0">{image ? '· ' : ''}{status}</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
onClick={() => onNavigate(nodeId)}
|
|
title="Open in editor"
|
|
>
|
|
<ExternalLink className="w-3 h-3" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StackSection({ stackName, nodeId, onNavigate, labelMap }: {
|
|
stackName: string;
|
|
nodeId: number;
|
|
onNavigate: (nodeId: number, stackName: string) => void;
|
|
labelMap?: Record<string, StackLabel[]>;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [containers, setContainers] = useState<StackContainer[] | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleExpand = async () => {
|
|
const next = !expanded;
|
|
setExpanded(next);
|
|
|
|
if (next && containers === null) {
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiFetch(`/fleet/node/${nodeId}/stacks/${encodeURIComponent(stackName)}/containers`, { localOnly: true });
|
|
if (res.ok) {
|
|
setContainers(await res.json());
|
|
} else {
|
|
toast.error('Failed to load containers for ' + stackName);
|
|
}
|
|
} catch {
|
|
toast.error('Failed to load containers for ' + stackName);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const runningCount = containers?.filter(c => c.State?.toLowerCase() === 'running').length ?? 0;
|
|
const totalCount = containers?.length ?? 0;
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
onClick={handleExpand}
|
|
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors text-left group"
|
|
>
|
|
{expanded ? <ChevronDown className="w-3 h-3 shrink-0" /> : <ChevronRight className="w-3 h-3 shrink-0" />}
|
|
<Layers className="w-3 h-3 text-muted-foreground shrink-0" />
|
|
<span className="truncate flex-1">{stackName}</span>
|
|
{labelMap?.[stackName]?.length ? (
|
|
<span className="flex items-center gap-0.5 shrink-0">
|
|
{labelMap[stackName].map(l => (
|
|
<LabelDot key={l.id} color={l.color} />
|
|
))}
|
|
</span>
|
|
) : null}
|
|
{containers !== null && (
|
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
{runningCount}/{totalCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
{expanded && (
|
|
<div className="ml-4 mt-1 space-y-0.5">
|
|
{loading ? (
|
|
<div className="space-y-2 px-3 py-1">
|
|
<Skeleton className="h-5 w-full" />
|
|
<Skeleton className="h-5 w-3/4" />
|
|
</div>
|
|
) : containers && containers.length > 0 ? (
|
|
containers.map(c => (
|
|
<ContainerRow
|
|
key={c.Id ?? containerName(c)}
|
|
container={c}
|
|
nodeId={nodeId}
|
|
onNavigate={(nid) => onNavigate(nid, stackName)}
|
|
/>
|
|
))
|
|
) : (
|
|
<p className="text-[10px] text-muted-foreground px-3 py-1">No containers</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UpdateStatusBadge({ status, error, onRetry, onDismiss }: {
|
|
status: NodeUpdateStatus['updateStatus'];
|
|
error?: string | null;
|
|
onRetry?: () => void;
|
|
onDismiss?: () => void;
|
|
}) {
|
|
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' || status === 'failed') {
|
|
const label = status === 'timeout' ? 'Timed out' : 'Failed';
|
|
return (
|
|
<CursorProvider>
|
|
<CursorContainer className="flex items-center gap-1">
|
|
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4 shrink-0">{label}</Badge>
|
|
{onRetry && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onRetry(); }}
|
|
className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
title="Retry update"
|
|
>
|
|
<RotateCcw className="w-3 h-3" strokeWidth={1.5} />
|
|
</button>
|
|
)}
|
|
{onDismiss && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onDismiss(); }}
|
|
className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
title="Dismiss"
|
|
>
|
|
<X className="w-3 h-3" strokeWidth={1.5} />
|
|
</button>
|
|
)}
|
|
{error && (
|
|
<>
|
|
<Cursor>
|
|
<div className="h-2 w-2 rounded-full bg-destructive/60" />
|
|
</Cursor>
|
|
<CursorFollow side="bottom" sideOffset={8} align="end" alignOffset={0}>
|
|
<div className="bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-card-border shadow-md rounded-lg px-3 py-2 max-w-xs">
|
|
<p className="font-mono tabular-nums text-xs text-stat-subtitle">{error}</p>
|
|
</div>
|
|
</CursorFollow>
|
|
</>
|
|
)}
|
|
</CursorContainer>
|
|
</CursorProvider>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
interface ReconnectingOverlayProps {
|
|
/** Gateway boot timestamp captured pre-update. Null falls back to offline-then-online detection. */
|
|
preUpdateStartedAt: number | null;
|
|
}
|
|
|
|
function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayProps) {
|
|
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;
|
|
let sawOffline = false;
|
|
const poll = setInterval(async () => {
|
|
try {
|
|
const res = await fetch('/api/health');
|
|
if (!res.ok) {
|
|
sawOffline = true;
|
|
return;
|
|
}
|
|
const data = await res.json().catch(() => null) as { startedAt?: number } | null;
|
|
const currentStartedAt = typeof data?.startedAt === 'number' ? data.startedAt : null;
|
|
|
|
if (preUpdateStartedAt !== null && currentStartedAt !== null) {
|
|
if (currentStartedAt !== preUpdateStartedAt) {
|
|
window.location.reload();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Fallback when we don't know the original startedAt: require an offline
|
|
// response first so we don't reload while the old process is still mid-pull.
|
|
if (sawOffline) {
|
|
window.location.reload();
|
|
}
|
|
} catch {
|
|
sawOffline = true;
|
|
}
|
|
}, 3000);
|
|
return () => clearInterval(poll);
|
|
}, [timedOut, preUpdateStartedAt]);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
interface NodeCardProps {
|
|
node: FleetNode;
|
|
onNavigate: (nodeId: number, stackName: string) => void;
|
|
labelMap?: Record<string, StackLabel[]>;
|
|
updateStatus?: NodeUpdateStatus;
|
|
onUpdate?: (nodeId: number) => void;
|
|
updatingNodeId?: number | null;
|
|
onRetryUpdate?: (nodeId: number) => void;
|
|
onDismissUpdate?: (nodeId: number) => void;
|
|
}
|
|
|
|
function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate }: NodeCardProps) {
|
|
const { isPaid } = useLicense();
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
|
|
const [loadingStacks, setLoadingStacks] = useState(false);
|
|
|
|
const isOnline = node.status === 'online';
|
|
const formattedVersion = formatVersion(updateStatus?.version);
|
|
const formattedLatest = formatVersion(updateStatus?.latestVersion);
|
|
const cpuPercent = getNodeCpu(node);
|
|
const memPercent = getNodeMem(node);
|
|
const diskPercent = getNodeDisk(node);
|
|
|
|
const handleExpand = async () => {
|
|
if (!isPaid) return;
|
|
const next = !expanded;
|
|
setExpanded(next);
|
|
|
|
if (next && !stacks) {
|
|
setLoadingStacks(true);
|
|
try {
|
|
const res = await apiFetch(`/fleet/node/${node.id}/stacks`, { localOnly: true });
|
|
if (res.ok) {
|
|
setStacks(await res.json());
|
|
} else {
|
|
toast.error('Failed to load stacks for ' + node.name);
|
|
}
|
|
} catch {
|
|
toast.error('Failed to load stacks for ' + node.name);
|
|
} finally {
|
|
setLoadingStacks(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`rounded-xl border bg-card text-card-foreground transition-all ${isOnline ? '' : 'opacity-60'}`}>
|
|
{/* Card Header */}
|
|
<div className="p-4 pb-3">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${isOnline ? 'bg-success-muted' : 'bg-muted'}`}>
|
|
<Server className={`w-4 h-4 ${isOnline ? 'text-success' : 'text-muted-foreground'}`} />
|
|
</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 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 shrink-0">
|
|
{node.type}
|
|
</Badge>
|
|
{formattedVersion && (
|
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 font-mono tabular-nums shrink-0">
|
|
{formattedVersion}
|
|
</Badge>
|
|
)}
|
|
{updateStatus?.updateStatus && (
|
|
<UpdateStatusBadge
|
|
status={updateStatus.updateStatus}
|
|
error={updateStatus.error}
|
|
onRetry={onRetryUpdate ? () => onRetryUpdate(node.id) : undefined}
|
|
onDismiss={onDismissUpdate ? () => onDismissUpdate(node.id) : undefined}
|
|
/>
|
|
)}
|
|
{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 shrink-0">
|
|
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" /> Critical
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Container Stats */}
|
|
{isOnline && node.stats && (
|
|
<div className="grid grid-cols-3 gap-2 mb-3">
|
|
<div className="bg-muted/50 rounded-lg px-2.5 py-2 text-center">
|
|
<div className="text-lg font-medium leading-none tabular-nums">{node.stats.active}</div>
|
|
<div className="text-[10px] text-muted-foreground mt-1">Running</div>
|
|
</div>
|
|
<div className="bg-muted/50 rounded-lg px-2.5 py-2 text-center">
|
|
<div className="text-lg font-medium leading-none tabular-nums">{node.stats.exited}</div>
|
|
<div className="text-[10px] text-muted-foreground mt-1">Stopped</div>
|
|
</div>
|
|
<div className="bg-muted/50 rounded-lg px-2.5 py-2 text-center">
|
|
<div className="text-lg font-medium leading-none tabular-nums">{node.stacks?.length ?? '-'}</div>
|
|
<div className="text-[10px] text-muted-foreground mt-1">Stacks</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Resource Usage Bars */}
|
|
{isOnline && node.systemStats && (
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="flex items-center justify-between text-xs mb-1">
|
|
<span className="flex items-center gap-1 text-muted-foreground">
|
|
<Cpu className="w-3 h-3" /> CPU
|
|
</span>
|
|
<span className="font-medium">{node.systemStats.cpu.usage}%</span>
|
|
</div>
|
|
<UsageBar percent={cpuPercent} color={cpuPercent > 80 ? 'bg-red-500' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center justify-between text-xs mb-1">
|
|
<span className="flex items-center gap-1 text-muted-foreground">
|
|
<MemoryStick className="w-3 h-3" /> RAM
|
|
</span>
|
|
<span className="font-medium">{formatBytes(node.systemStats.memory.used)} / {formatBytes(node.systemStats.memory.total)}</span>
|
|
</div>
|
|
<UsageBar percent={memPercent} color={memPercent > 80 ? 'bg-red-500' : memPercent > 60 ? 'bg-warning' : 'bg-info'} />
|
|
</div>
|
|
{node.systemStats.disk && (
|
|
<div>
|
|
<div className="flex items-center justify-between text-xs mb-1">
|
|
<span className="flex items-center gap-1 text-muted-foreground">
|
|
<HardDrive className="w-3 h-3" /> Disk
|
|
</span>
|
|
<span className="font-medium">{formatBytes(node.systemStats.disk.used)} / {formatBytes(node.systemStats.disk.total)}</span>
|
|
</div>
|
|
<UsageBar percent={diskPercent} color={diskPercent > 90 ? 'bg-red-500' : diskPercent > 75 ? 'bg-warning' : 'bg-violet-500'} />
|
|
</div>
|
|
)}
|
|
</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} />{formattedLatest ? `Update to ${formattedLatest}` : 'Update'}</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Offline placeholder */}
|
|
{!isOnline && (
|
|
<div className="flex items-center justify-center py-6 text-muted-foreground text-sm">
|
|
Node unreachable
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Paid: Expandable Stack List with Container Drill-Down */}
|
|
{isOnline && isPaid && (
|
|
<div className="border-t">
|
|
<button
|
|
onClick={handleExpand}
|
|
className="flex items-center gap-2 w-full px-4 py-2.5 text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
|
|
>
|
|
{expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
|
<Layers className="w-3.5 h-3.5" />
|
|
Stack details
|
|
{stacks !== null && (
|
|
<span className="ml-auto text-[10px]">{stacks.length} stacks</span>
|
|
)}
|
|
</button>
|
|
{expanded && (
|
|
<div className="px-2 pb-3">
|
|
{loadingStacks ? (
|
|
<div className="space-y-2 px-2">
|
|
<Skeleton className="h-6 w-full" />
|
|
<Skeleton className="h-6 w-3/4" />
|
|
</div>
|
|
) : stacks && stacks.length > 0 ? (
|
|
<div className="space-y-0.5">
|
|
{stacks.map(stack => (
|
|
<StackSection
|
|
key={stack}
|
|
stackName={stack}
|
|
nodeId={node.id}
|
|
onNavigate={onNavigate}
|
|
labelMap={labelMap}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground py-1 px-2">No stacks found</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Main Component ---
|
|
|
|
interface FleetViewProps {
|
|
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
|
}
|
|
|
|
export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|
const [nodes, setNodes] = useState<FleetNode[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [prefs, setPrefs] = useState<FleetPreferences>(loadPreferences);
|
|
const [fleetLabels, setFleetLabels] = useState<StackLabel[]>([]);
|
|
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<string, StackLabel[]>>({});
|
|
const [labelFilters, setLabelFilters] = useState<Set<number>>(new Set());
|
|
const { isPaid } = useLicense();
|
|
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
|
|
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
|
|
const [reconnecting, setReconnecting] = useState(false);
|
|
const [preUpdateStartedAt, setPreUpdateStartedAt] = useState<number | null>(null);
|
|
const [localUpdateConfirm, setLocalUpdateConfirm] = useState<number | null>(null);
|
|
const [showUpdateModal, setShowUpdateModal] = useState(false);
|
|
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
|
const [recheckingUpdates, setRecheckingUpdates] = useState(false);
|
|
const [modalSearch, setModalSearch] = useState('');
|
|
const updateStatusesRef = useRef(updateStatuses);
|
|
updateStatusesRef.current = updateStatuses;
|
|
|
|
const updatePrefs = useCallback((update: Partial<FleetPreferences>) => {
|
|
setPrefs(prev => {
|
|
const next = { ...prev, ...update };
|
|
savePreferences(next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const fetchOverview = useCallback(async (showRefresh = false) => {
|
|
if (showRefresh) setRefreshing(true);
|
|
try {
|
|
const res = await apiFetch('/fleet/overview', { localOnly: true });
|
|
if (res.ok) {
|
|
setNodes(await res.json());
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch fleet overview:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
}, []);
|
|
|
|
const fetchLabels = useCallback(async () => {
|
|
if (!isPaid) return;
|
|
try {
|
|
const [labelsRes, assignmentsRes] = await Promise.all([
|
|
apiFetch('/labels', { localOnly: true }),
|
|
apiFetch('/labels/assignments', { localOnly: true }),
|
|
]);
|
|
if (labelsRes.ok) setFleetLabels(await labelsRes.json());
|
|
if (assignmentsRes.ok) setFleetStackLabelMap(await assignmentsRes.json());
|
|
} catch {
|
|
// Non-critical
|
|
}
|
|
}, [isPaid]);
|
|
|
|
const fetchUpdateStatus = useCallback(async () => {
|
|
if (!isPaid) 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 */ }
|
|
}, [isPaid]);
|
|
|
|
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 {
|
|
// Capture pre-update boot timestamp so the overlay can detect a real restart
|
|
// vs a false "online" response from the still-running old process mid-pull.
|
|
let bootBefore: number | null = null;
|
|
try {
|
|
const healthRes = await fetch('/api/health');
|
|
if (healthRes.ok) {
|
|
const data = await healthRes.json();
|
|
if (typeof data?.startedAt === 'number') bootBefore = data.startedAt;
|
|
}
|
|
} catch { /* fall back to offline-then-online detection */ }
|
|
|
|
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true });
|
|
if (res.ok) {
|
|
setPreUpdateStartedAt(bootBefore);
|
|
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]);
|
|
|
|
const dismissNodeUpdate = useCallback(async (nodeId: number) => {
|
|
try {
|
|
await apiFetch(`/fleet/nodes/${nodeId}/update-status`, { method: 'DELETE', localOnly: true });
|
|
fetchUpdateStatus();
|
|
} catch (error) {
|
|
console.error('[Fleet] Failed to dismiss update status:', error);
|
|
}
|
|
}, [fetchUpdateStatus]);
|
|
|
|
const retryNodeUpdate = useCallback(async (nodeId: number) => {
|
|
triggerNodeUpdate(nodeId);
|
|
}, [triggerNodeUpdate]);
|
|
|
|
useEffect(() => {
|
|
fetchOverview();
|
|
fetchLabels();
|
|
fetchUpdateStatus();
|
|
}, [fetchOverview, fetchLabels, fetchUpdateStatus]);
|
|
|
|
// Paid tier: auto-refresh every 30s
|
|
useEffect(() => {
|
|
if (!isPaid) return;
|
|
const overviewInterval = setInterval(fetchOverview, 30000);
|
|
const updateInterval = setInterval(fetchUpdateStatus, 120000);
|
|
return () => { clearInterval(overviewInterval); clearInterval(updateInterval); };
|
|
}, [isPaid, fetchOverview, fetchUpdateStatus]);
|
|
|
|
// 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]);
|
|
const onlineCount = onlineNodes.length;
|
|
const totalContainers = nodes.reduce((sum, n) => sum + (n.stats?.active ?? 0), 0);
|
|
const totalContainersAll = nodes.reduce((sum, n) => sum + (n.stats?.total ?? 0), 0);
|
|
const totalStacks = nodes.reduce((sum, n) => sum + (n.stacks?.length ?? 0), 0);
|
|
const criticalCount = onlineNodes.filter(isCritical).length;
|
|
|
|
const avgCpu = onlineNodes.length > 0
|
|
? (onlineNodes.reduce((sum, n) => sum + getNodeCpu(n), 0) / onlineNodes.length).toFixed(1)
|
|
: '0';
|
|
const worstCpuNode = onlineNodes.length > 0
|
|
? onlineNodes.reduce((worst, n) => getNodeCpu(n) > getNodeCpu(worst) ? n : worst, onlineNodes[0])
|
|
: null;
|
|
|
|
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 (Skipper+) ---
|
|
|
|
const processedNodes = useMemo(() => {
|
|
let filtered = [...nodes];
|
|
|
|
// Search (paid only, but harmless if applied - free users won't see the search bar)
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase();
|
|
filtered = filtered.filter(n =>
|
|
n.name.toLowerCase().includes(q) ||
|
|
n.stacks?.some(s => s.toLowerCase().includes(q))
|
|
);
|
|
}
|
|
|
|
if (isPaid) {
|
|
// Status filter
|
|
if (prefs.filterStatus === 'online') filtered = filtered.filter(n => n.status === 'online');
|
|
if (prefs.filterStatus === 'offline') filtered = filtered.filter(n => n.status !== 'online');
|
|
|
|
// Type filter
|
|
if (prefs.filterType === 'local') filtered = filtered.filter(n => n.type === 'local');
|
|
if (prefs.filterType === 'remote') filtered = filtered.filter(n => n.type === 'remote');
|
|
|
|
// Critical filter
|
|
if (prefs.filterCritical) filtered = filtered.filter(isCritical);
|
|
|
|
// Label filter
|
|
if (labelFilters.size > 0) {
|
|
filtered = filtered.filter(n =>
|
|
n.stacks?.some(s => {
|
|
const sLabels = fleetStackLabelMap[s] || [];
|
|
return sLabels.some(l => labelFilters.has(l.id));
|
|
})
|
|
);
|
|
}
|
|
|
|
// Sort
|
|
filtered.sort((a, b) => {
|
|
let cmp = 0;
|
|
switch (prefs.sortBy) {
|
|
case 'name':
|
|
cmp = a.name.localeCompare(b.name);
|
|
break;
|
|
case 'cpu':
|
|
cmp = getNodeCpu(b) - getNodeCpu(a);
|
|
break;
|
|
case 'memory':
|
|
cmp = getNodeMem(b) - getNodeMem(a);
|
|
break;
|
|
case 'containers':
|
|
cmp = (b.stats?.active ?? 0) - (a.stats?.active ?? 0);
|
|
break;
|
|
case 'status':
|
|
cmp = (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1);
|
|
break;
|
|
}
|
|
return prefs.sortDir === 'desc' ? -cmp : cmp;
|
|
});
|
|
}
|
|
|
|
return filtered;
|
|
}, [nodes, searchQuery, isPaid, prefs, labelFilters, fleetStackLabelMap]);
|
|
|
|
return (
|
|
<div className="h-full overflow-auto p-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-medium tracking-tight">Fleet Overview</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{loading ? 'Loading...' : `${onlineCount} of ${nodes.length} nodes online · ${totalContainers} containers · ${totalStacks} stacks`}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isPaid && (
|
|
<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">
|
|
<TabsList>
|
|
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
|
<TabsHighlightItem value="overview">
|
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
</TabsHighlightItem>
|
|
{isPaid && (
|
|
<TabsHighlightItem value="snapshots">
|
|
<TabsTrigger value="snapshots">
|
|
<Camera className="w-4 h-4 mr-1.5" />Snapshots
|
|
</TabsTrigger>
|
|
</TabsHighlightItem>
|
|
)}
|
|
</TabsHighlight>
|
|
</TabsList>
|
|
|
|
<TabsContent value="overview">
|
|
{/* Loading State */}
|
|
{loading && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<div key={i} className="rounded-xl border bg-card p-4 space-y-3">
|
|
<Skeleton className="h-8 w-32" />
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<Skeleton className="h-14 rounded-lg" />
|
|
<Skeleton className="h-14 rounded-lg" />
|
|
<Skeleton className="h-14 rounded-lg" />
|
|
</div>
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-3/4" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!loading && nodes.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
<Server className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
|
<h3 className="text-lg font-medium mb-1">No nodes configured</h3>
|
|
<p className="text-sm text-muted-foreground">Add nodes in Settings to see your fleet here.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Fleet Content */}
|
|
{!loading && nodes.length > 0 && (
|
|
<>
|
|
{/* Paid: Fleet Health Summary Cards */}
|
|
{isPaid && onlineNodes.length > 0 && (
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
|
<StatCard
|
|
icon={Box}
|
|
label="Containers"
|
|
value={`${totalContainers}`}
|
|
sub={`${totalContainersAll} total across fleet`}
|
|
/>
|
|
<StatCard
|
|
icon={Activity}
|
|
label="Fleet CPU"
|
|
value={`${avgCpu}%`}
|
|
sub={worstCpuNode ? `Peak: ${worstCpuNode.name} (${worstCpuNode.systemStats?.cpu.usage}%)` : undefined}
|
|
/>
|
|
<StatCard
|
|
icon={MemoryStick}
|
|
label="Fleet Memory"
|
|
value={formatBytes(totalMemUsed)}
|
|
sub={totalMemTotal > 0 ? `of ${formatBytes(totalMemTotal)} (${((totalMemUsed / totalMemTotal) * 100).toFixed(0)}%)` : undefined}
|
|
/>
|
|
<StatCard
|
|
icon={AlertTriangle}
|
|
label="Alerts"
|
|
value={`${criticalCount}`}
|
|
sub={criticalCount > 0 ? `${criticalCount} node${criticalCount > 1 ? 's' : ''} above 90% CPU or disk` : 'All nodes healthy'}
|
|
alert={criticalCount > 0}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Paid: Search, Sort & Filter Toolbar */}
|
|
{isPaid && (
|
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
|
{/* Search */}
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
|
<Input
|
|
placeholder="Search nodes or stacks..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9 h-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* Sort */}
|
|
<Select value={prefs.sortBy} onValueChange={(v) => updatePrefs({ sortBy: v as SortField })}>
|
|
<SelectTrigger className="w-[150px] h-9">
|
|
<ArrowUpDown className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="name">Name</SelectItem>
|
|
<SelectItem value="cpu">CPU Usage</SelectItem>
|
|
<SelectItem value="memory">Memory Usage</SelectItem>
|
|
<SelectItem value="containers">Containers</SelectItem>
|
|
<SelectItem value="status">Status</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-9 w-9 p-0"
|
|
onClick={() => updatePrefs({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
|
|
title={prefs.sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
|
>
|
|
<ArrowUpDown className={`w-4 h-4 ${prefs.sortDir === 'desc' ? 'rotate-180' : ''} transition-transform`} />
|
|
</Button>
|
|
|
|
{/* Filter pills */}
|
|
<div className="flex items-center gap-1.5">
|
|
{(['all', 'online', 'offline'] as FilterStatus[]).map(status => (
|
|
<Button
|
|
key={status}
|
|
variant={prefs.filterStatus === status ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="h-7 text-xs px-2.5"
|
|
onClick={() => updatePrefs({ filterStatus: status })}
|
|
>
|
|
{status === 'all' ? 'All' : status === 'online' ? (
|
|
<><Play className="w-3 h-3 mr-1" />Online</>
|
|
) : (
|
|
<><Square className="w-3 h-3 mr-1" />Offline</>
|
|
)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
{(['all', 'local', 'remote'] as FilterType[]).map(type => (
|
|
<Button
|
|
key={type}
|
|
variant={prefs.filterType === type ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="h-7 text-xs px-2.5"
|
|
onClick={() => updatePrefs({ filterType: type })}
|
|
>
|
|
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
<Button
|
|
variant={prefs.filterCritical ? 'destructive' : 'outline'}
|
|
size="sm"
|
|
className="h-7 text-xs px-2.5"
|
|
onClick={() => updatePrefs({ filterCritical: !prefs.filterCritical })}
|
|
>
|
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
|
Critical Only
|
|
</Button>
|
|
|
|
{fleetLabels.length > 0 && (
|
|
<>
|
|
<div className="w-px h-5 bg-border mx-1" />
|
|
<MultiSelectCombobox
|
|
options={fleetLabels.map(l => ({ value: String(l.id), label: l.name, color: l.color }))}
|
|
selected={new Set(Array.from(labelFilters).map(String))}
|
|
onSelectionChange={(sel) => setLabelFilters(new Set(Array.from(sel).map(Number)))}
|
|
placeholder="Tags"
|
|
renderOption={(option) => (
|
|
<span className="flex items-center gap-1.5">
|
|
<LabelDot color={option.color as StackLabel['color'] ?? 'slate'} />
|
|
{option.label}
|
|
</span>
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Node Grid */}
|
|
{processedNodes.length > 0 ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{processedNodes.map(node => (
|
|
<NodeCard
|
|
key={node.id}
|
|
node={node}
|
|
onNavigate={onNavigateToNode}
|
|
labelMap={fleetStackLabelMap}
|
|
updateStatus={updateStatusMap.get(node.id)}
|
|
onUpdate={isPaid ? triggerNodeUpdate : undefined}
|
|
updatingNodeId={updatingNodeId}
|
|
onRetryUpdate={isPaid ? retryNodeUpdate : undefined}
|
|
onDismissUpdate={isPaid ? dismissNodeUpdate : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<Search className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
|
<h3 className="text-sm font-medium mb-1">No nodes match your filters</h3>
|
|
<p className="text-xs text-muted-foreground">Try adjusting your search or filter criteria.</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-3"
|
|
onClick={() => {
|
|
setSearchQuery('');
|
|
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
|
|
setLabelFilters(new Set());
|
|
}}
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
|
Clear filters
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Paid tier auto-refresh indicator */}
|
|
{isPaid && (
|
|
<p className="text-xs text-muted-foreground text-center mt-6">
|
|
Auto-refreshing every 30 seconds
|
|
</p>
|
|
)}
|
|
|
|
{/* Free tier: paid gate for advanced features */}
|
|
{!isPaid && nodes.length > 0 && (
|
|
<div className="mt-6">
|
|
<PaidGate featureName="Fleet Management">
|
|
{/* Preview of what paid tier unlocks */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
|
<div className="rounded-xl border bg-card p-4 h-24" />
|
|
<div className="rounded-xl border bg-card p-4 h-24" />
|
|
<div className="rounded-xl border bg-card p-4 h-24" />
|
|
<div className="rounded-xl border bg-card p-4 h-24" />
|
|
</div>
|
|
<div className="flex gap-3 mb-4">
|
|
<div className="h-9 rounded-md border bg-card flex-1 max-w-sm" />
|
|
<div className="h-9 rounded-md border bg-card w-[150px]" />
|
|
</div>
|
|
</PaidGate>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{isPaid && (
|
|
<TabsContent value="snapshots">
|
|
<FleetSnapshots />
|
|
</TabsContent>
|
|
)}
|
|
</Tabs>
|
|
|
|
{/* Reconnecting overlay — shown when local node is updating */}
|
|
{reconnecting && <ReconnectingOverlay preUpdateStartedAt={preUpdateStartedAt} />}
|
|
|
|
{/* 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;
|
|
const gatewayLabel = formatVersion(updateStatuses[0]?.latestVersion);
|
|
|
|
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>
|
|
{gatewayLabel && (
|
|
<div className="text-[11px] text-muted-foreground shrink-0">
|
|
Latest: <span className="font-mono tabular-nums text-foreground">{gatewayLabel}</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">
|
|
{formatVersion(s.version) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
|
|
</span>
|
|
|
|
{/* Latest version */}
|
|
<span className="text-xs font-mono tabular-nums">
|
|
{formatVersion(s.latestVersion) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
|
|
</span>
|
|
|
|
{/* Status / Action */}
|
|
<div className="flex justify-end">
|
|
{s.updateStatus && (
|
|
<UpdateStatusBadge
|
|
status={s.updateStatus}
|
|
error={s.error}
|
|
onRetry={() => retryNodeUpdate(s.nodeId)}
|
|
onDismiss={() => dismissNodeUpdate(s.nodeId)}
|
|
/>
|
|
)}
|
|
{!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 “{modalSearch}”
|
|
</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"
|
|
disabled={recheckingUpdates}
|
|
onClick={async () => {
|
|
setRecheckingUpdates(true);
|
|
await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true });
|
|
await fetchUpdateStatus();
|
|
setRecheckingUpdates(false);
|
|
}}
|
|
>
|
|
<RefreshCw className={`w-3 h-3 mr-1.5 ${recheckingUpdates ? 'animate-spin' : ''}`} 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>
|
|
);
|
|
}
|