mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
refactor(frontend): extract NodeCard and OverviewTab from FleetView (F5-3+F5-5) (#919)
* refactor(frontend): extract NodeCard and OverviewTab from FleetView (F5-3+F5-5) Folds F5-3 (NodeCard) and F5-5 (OverviewTab) into a single PR since NodeCard was never previously extracted. - Move FleetNodeStats, FleetNodeSystemStats, FleetNode into FleetView/types.ts - Extract NodeCard (~200 LOC) including UsageBar, ContainerRow, StackSection sub-components and getNodeCpu/getNodeMem/getNodeDisk/isCritical helpers - Extract OverviewTab (~115 LOC); delegates to NodeCard, OverviewToolbar, FleetTopology; receives all state as flat props from FleetView - FleetView.tsx drops from ~1,107 to ~480 LOC (overview inline body gone) - No logic moved; all state, hooks, and computed values remain in FleetView.tsx - formatBytes consolidated to @/lib/utils; node helpers exported from NodeCard - allNodes wrapped in useMemo to prevent unnecessary child re-renders * fix(frontend): move node utility functions to nodeUtils.ts to fix react-refresh lint error getNodeCpu, getNodeMem, getNodeDisk, and isCritical were exported from NodeCard.tsx alongside a React component, violating the react-refresh/only-export-components rule. Moving them to a dedicated nodeUtils.ts resolves the ESLint error without changing any logic.
This commit is contained in:
@@ -1,23 +1,18 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useExperimental } from '@/hooks/useExperimental';
|
||||
import {
|
||||
Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight,
|
||||
Layers, Wifi, WifiOff, Search, AlertTriangle,
|
||||
RotateCcw, ExternalLink, Camera, Download, Loader2,
|
||||
RefreshCw, Search, Camera,
|
||||
Network, SlidersHorizontal,
|
||||
Send, KeyRound, ArrowLeftRight,
|
||||
} from 'lucide-react';
|
||||
import { FleetMasthead } from './fleet/FleetMasthead';
|
||||
import { FleetTopology } from './fleet/FleetTopology';
|
||||
import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay';
|
||||
import { NodeUpdatesSheet } from './FleetView/NodeUpdatesSheet';
|
||||
import { LocalUpdateConfirmDialog } from './FleetView/LocalUpdateConfirmDialog';
|
||||
import { UpdateStatusBadge } from './FleetView/UpdateStatusBadge';
|
||||
import type { NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './FleetView/types';
|
||||
import { OverviewToolbar } from './FleetView/OverviewToolbar';
|
||||
import { isCritical, getNodeCpu, getNodeMem, getNodeDisk } from './FleetView/nodeUtils';
|
||||
import { OverviewTab } from './FleetView/OverviewTab';
|
||||
import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './FleetView/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
||||
import { springs } from '@/lib/motion';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
@@ -29,48 +24,12 @@ import { FleetSoonPlaceholder, SoonBadge } from './fleet/FleetSoonPlaceholder';
|
||||
import { RoutingTab } from './fleet/RoutingTab';
|
||||
import { DeploymentsTab } from './blueprints/DeploymentsTab';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { LabelDot } from './LabelPill';
|
||||
import { type Label as StackLabel, type LabelColor } from './label-types';
|
||||
import { formatVersion } from '@/lib/version';
|
||||
|
||||
function labelPaletteKey(name: string, color: LabelColor): string {
|
||||
return `${name.trim().toLowerCase()}|${color}`;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
const PREFS_KEY = 'sencho-fleet-preferences';
|
||||
|
||||
function loadPreferences(): FleetPreferences {
|
||||
@@ -85,409 +44,6 @@ 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 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-destructive';
|
||||
|
||||
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" strokeWidth={1.5} />
|
||||
</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 () => {
|
||||
if (loading) return;
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
|
||||
if (next) {
|
||||
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 (error) {
|
||||
console.error('Failed to load containers for', stackName, error);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 isLocal = node.type === 'local';
|
||||
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 (error) {
|
||||
console.error('Failed to load stacks for', node.name, error);
|
||||
toast.error('Failed to load stacks for ' + node.name);
|
||||
} finally {
|
||||
setLoadingStacks(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const localRailClasses = isLocal
|
||||
? 'relative overflow-hidden ring-1 ring-brand/30 before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-brand before:rounded-l-xl after:pointer-events-none after:absolute after:inset-0 after:bg-gradient-to-r after:from-brand/[0.06] after:via-transparent after:to-transparent'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover ${localRailClasses} ${isOnline ? '' : 'opacity-60'}`}>
|
||||
{/* Card Header */}
|
||||
<div className="relative p-4 pb-3">
|
||||
{isLocal && (
|
||||
<span className="absolute top-3 right-3 font-mono text-[9px] uppercase tracking-[0.22em] text-brand">
|
||||
★ Local
|
||||
</span>
|
||||
)}
|
||||
<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 mb-3 rounded-md border border-card-border overflow-hidden">
|
||||
<div className="border-r border-card-border bg-card px-2.5 py-2 text-center">
|
||||
<div className="text-lg font-medium leading-none tabular-nums text-stat-value">{node.stats.active}</div>
|
||||
<div className="text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle mt-1">Running</div>
|
||||
</div>
|
||||
<div className="border-r border-card-border bg-card px-2.5 py-2 text-center">
|
||||
<div className="text-lg font-medium leading-none tabular-nums text-stat-value">{node.stats.exited}</div>
|
||||
<div className="text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle mt-1">Stopped</div>
|
||||
</div>
|
||||
<div className="bg-card px-2.5 py-2 text-center">
|
||||
<div className="text-lg font-medium leading-none tabular-nums text-stat-value">{node.stacks?.length ?? '-'}</div>
|
||||
<div className="text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle 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-destructive/80' : 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-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} />
|
||||
</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-destructive/80' : diskPercent > 75 ? 'bg-warning' : 'bg-brand'} />
|
||||
</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;
|
||||
}
|
||||
@@ -830,7 +386,10 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
|
||||
setLabelFilters(new Set());
|
||||
}, [updatePrefs]);
|
||||
const allNodes = localNode ? [localNode, ...remoteNodes] : remoteNodes;
|
||||
const allNodes = useMemo(
|
||||
() => (localNode ? [localNode, ...remoteNodes] : remoteNodes),
|
||||
[localNode, remoteNodes]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
@@ -930,101 +489,31 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</div>
|
||||
|
||||
<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 && (
|
||||
<>
|
||||
<OverviewToolbar
|
||||
isPaid={isPaid}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
prefs={prefs}
|
||||
onPrefsChange={updatePrefs}
|
||||
fleetPalette={fleetPalette}
|
||||
labelFilters={labelFilters}
|
||||
onLabelFiltersChange={setLabelFilters}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
|
||||
{viewMode === 'topology' && processedNodes.length > 0 ? (
|
||||
<FleetTopology
|
||||
nodes={topologyNodes}
|
||||
onNodeClick={(id) => onNavigateToNode(id, '')}
|
||||
/>
|
||||
) : processedNodes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 items-start">
|
||||
{allNodes.map(node => (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
onNavigate={onNavigateToNode}
|
||||
labelMap={fleetStackLabelMap[node.id] ?? {}}
|
||||
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('');
|
||||
clearFilters();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
<OverviewTab
|
||||
loading={loading}
|
||||
nodes={nodes}
|
||||
processedNodes={processedNodes}
|
||||
allNodes={allNodes}
|
||||
topologyNodes={topologyNodes}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
prefs={prefs}
|
||||
onPrefsChange={updatePrefs}
|
||||
fleetPalette={fleetPalette}
|
||||
labelFilters={labelFilters}
|
||||
onLabelFiltersChange={setLabelFilters}
|
||||
onClearFilters={clearFilters}
|
||||
isPaid={isPaid}
|
||||
fleetStackLabelMap={fleetStackLabelMap}
|
||||
updateStatusMap={updateStatusMap}
|
||||
onNavigateToNode={onNavigateToNode}
|
||||
onUpdate={isPaid ? triggerNodeUpdate : undefined}
|
||||
updatingNodeId={updatingNodeId}
|
||||
onRetryUpdate={isPaid ? retryNodeUpdate : undefined}
|
||||
onDismissUpdate={isPaid ? dismissNodeUpdate : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{isPaid && (
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Server, Cpu, MemoryStick, HardDrive, ChevronDown, ChevronRight,
|
||||
Layers, Wifi, WifiOff, AlertTriangle, ExternalLink, Download, Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { LabelDot } from '../LabelPill';
|
||||
import { formatVersion } from '@/lib/version';
|
||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||
import type { Label as StackLabel } from '../label-types';
|
||||
import type { FleetNode, NodeUpdateStatus } from './types';
|
||||
import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from './nodeUtils';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface StackContainer {
|
||||
Id?: string;
|
||||
Names?: string[];
|
||||
Image?: string;
|
||||
State?: string;
|
||||
Status?: string;
|
||||
}
|
||||
|
||||
export 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 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 ContainerRow({ container, onNavigate }: {
|
||||
container: StackContainer;
|
||||
onNavigate: () => 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-destructive';
|
||||
|
||||
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}
|
||||
title="Open in editor"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</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 () => {
|
||||
if (loading) return;
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
|
||||
if (next) {
|
||||
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 (error) {
|
||||
console.error('Failed to load containers for', stackName, error);
|
||||
toast.error('Failed to load containers for ' + stackName);
|
||||
setExpanded(false);
|
||||
} 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}
|
||||
onNavigate={() => onNavigate(nodeId, stackName)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground px-3 py-1">No containers</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Export ---
|
||||
|
||||
export 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 isLocal = node.type === 'local';
|
||||
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 (error) {
|
||||
console.error('Failed to load stacks for', node.name, error);
|
||||
toast.error('Failed to load stacks for ' + node.name);
|
||||
setExpanded(false);
|
||||
} finally {
|
||||
setLoadingStacks(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const localRailClasses = isLocal
|
||||
? 'relative overflow-hidden ring-1 ring-brand/30 before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-brand before:rounded-l-xl after:pointer-events-none after:absolute after:inset-0 after:bg-gradient-to-r after:from-brand/[0.06] after:via-transparent after:to-transparent'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover ${localRailClasses} ${isOnline ? '' : 'opacity-60'}`}>
|
||||
{/* Card Header */}
|
||||
<div className="relative p-4 pb-3">
|
||||
{isLocal && (
|
||||
<span className="absolute top-3 right-3 font-mono text-[9px] uppercase tracking-[0.22em] text-brand">
|
||||
★ Local
|
||||
</span>
|
||||
)}
|
||||
<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 mb-3 rounded-md border border-card-border overflow-hidden">
|
||||
<div className="border-r border-card-border bg-card px-2.5 py-2 text-center">
|
||||
<div className="text-lg font-medium leading-none tabular-nums text-stat-value">{node.stats.active}</div>
|
||||
<div className="text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle mt-1">Running</div>
|
||||
</div>
|
||||
<div className="border-r border-card-border bg-card px-2.5 py-2 text-center">
|
||||
<div className="text-lg font-medium leading-none tabular-nums text-stat-value">{node.stats.exited}</div>
|
||||
<div className="text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle mt-1">Stopped</div>
|
||||
</div>
|
||||
<div className="bg-card px-2.5 py-2 text-center">
|
||||
<div className="text-lg font-medium leading-none tabular-nums text-stat-value">{node.stacks?.length ?? '-'}</div>
|
||||
<div className="text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle 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-destructive/80' : 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, 1)} / {formatBytes(node.systemStats.memory.total, 1)}</span>
|
||||
</div>
|
||||
<UsageBar percent={memPercent} color={memPercent > 80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} />
|
||||
</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, 1)} / {formatBytes(node.systemStats.disk.total, 1)}</span>
|
||||
</div>
|
||||
<UsageBar percent={diskPercent} color={diskPercent > 90 ? 'bg-destructive/80' : diskPercent > 75 ? 'bg-warning' : 'bg-brand'} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Server, Search, RotateCcw } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FleetTopology } from '../fleet/FleetTopology';
|
||||
import { NodeCard } from './NodeCard';
|
||||
import { OverviewToolbar } from './OverviewToolbar';
|
||||
import type { FleetTopologyNode } from '@/lib/fleet-topology-layout';
|
||||
import type { Label as StackLabel } from '../label-types';
|
||||
import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './types';
|
||||
|
||||
interface OverviewTabProps {
|
||||
loading: boolean;
|
||||
nodes: FleetNode[];
|
||||
processedNodes: FleetNode[];
|
||||
allNodes: FleetNode[];
|
||||
topologyNodes: FleetTopologyNode[];
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (q: string) => void;
|
||||
prefs: FleetPreferences;
|
||||
onPrefsChange: (update: Partial<FleetPreferences>) => void;
|
||||
fleetPalette: FleetPaletteEntry[];
|
||||
labelFilters: Set<string>;
|
||||
onLabelFiltersChange: (filters: Set<string>) => void;
|
||||
onClearFilters: () => void;
|
||||
isPaid: boolean;
|
||||
fleetStackLabelMap: Record<number, Record<string, StackLabel[]>>;
|
||||
updateStatusMap: Map<number, NodeUpdateStatus>;
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
onUpdate?: (nodeId: number) => void;
|
||||
updatingNodeId: number | null;
|
||||
onRetryUpdate?: (nodeId: number) => void;
|
||||
onDismissUpdate?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
export function OverviewTab({
|
||||
loading,
|
||||
nodes,
|
||||
processedNodes,
|
||||
allNodes,
|
||||
topologyNodes,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
prefs,
|
||||
onPrefsChange,
|
||||
fleetPalette,
|
||||
labelFilters,
|
||||
onLabelFiltersChange,
|
||||
onClearFilters,
|
||||
isPaid,
|
||||
fleetStackLabelMap,
|
||||
updateStatusMap,
|
||||
onNavigateToNode,
|
||||
onUpdate,
|
||||
updatingNodeId,
|
||||
onRetryUpdate,
|
||||
onDismissUpdate,
|
||||
}: OverviewTabProps) {
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
)}
|
||||
|
||||
{!loading && nodes.length > 0 && (
|
||||
<>
|
||||
<OverviewToolbar
|
||||
isPaid={isPaid}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={onViewModeChange}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={onSearchQueryChange}
|
||||
prefs={prefs}
|
||||
onPrefsChange={onPrefsChange}
|
||||
fleetPalette={fleetPalette}
|
||||
labelFilters={labelFilters}
|
||||
onLabelFiltersChange={onLabelFiltersChange}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
|
||||
{viewMode === 'topology' && processedNodes.length > 0 ? (
|
||||
<FleetTopology
|
||||
nodes={topologyNodes}
|
||||
onNodeClick={(id) => onNavigateToNode(id, '')}
|
||||
/>
|
||||
) : processedNodes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 items-start">
|
||||
{allNodes.map(node => (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
onNavigate={onNavigateToNode}
|
||||
labelMap={fleetStackLabelMap[node.id] ?? {}}
|
||||
updateStatus={updateStatusMap.get(node.id)}
|
||||
onUpdate={onUpdate}
|
||||
updatingNodeId={updatingNodeId}
|
||||
onRetryUpdate={onRetryUpdate}
|
||||
onDismissUpdate={onDismissUpdate}
|
||||
/>
|
||||
))}
|
||||
</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={() => {
|
||||
onSearchQueryChange('');
|
||||
onClearFilters();
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPaid && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-6">
|
||||
Auto-refreshing every 30 seconds
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { FleetNode } from './types';
|
||||
|
||||
export function getNodeCpu(node: FleetNode): number {
|
||||
return node.systemStats ? parseFloat(node.systemStats.cpu.usage) : 0;
|
||||
}
|
||||
|
||||
export function getNodeMem(node: FleetNode): number {
|
||||
return node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0;
|
||||
}
|
||||
|
||||
export function getNodeDisk(node: FleetNode): number {
|
||||
return node.systemStats?.disk ? parseFloat(node.systemStats.disk.usagePercent) : 0;
|
||||
}
|
||||
|
||||
export function isCritical(node: FleetNode): boolean {
|
||||
return getNodeCpu(node) > 90 || getNodeDisk(node) > 90;
|
||||
}
|
||||
@@ -1,5 +1,29 @@
|
||||
import type { LabelColor } from '../label-types';
|
||||
|
||||
export interface FleetNodeStats {
|
||||
active: number;
|
||||
managed: number;
|
||||
unmanaged: number;
|
||||
exited: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export interface FleetNode {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
stats: FleetNodeStats | null;
|
||||
systemStats: FleetNodeSystemStats | null;
|
||||
stacks: string[] | null;
|
||||
}
|
||||
|
||||
export interface NodeUpdateStatus {
|
||||
nodeId: number;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user