Files
sencho/frontend/src/components/FleetView/NodeCard.tsx
T
Anso 16774ae515 feat(fleet): add node management actions to Fleet Overview (#1064)
* refactor(nodes): extract node create/edit/delete modals into useNodeActions hook

Pulls the inline Add/Edit/Delete/Pilot-enrollment modal stack out of
NodeManager.tsx and into a reusable useNodeActions() hook in
components/nodes/. Settings continues to consume the same modals via
this hook, with an onTestResult callback used by Settings to render the
existing connection-detail panel after a successful test.

The hook also extends the auto-test-on-save behavior so that saving a
proxy-mode remote node from the Edit dialog re-runs the connection test
when the API URL or token has actually changed (skipped when only name
or compose dir was edited).

* feat(fleet): surface Add/Edit/Delete node actions on Fleet Overview

Adds an admin-only Add node button to the right of Refresh on the Fleet
header, opening the same Add Node dialog used by Settings. Each node
card's three-dot menu now exposes Edit node and Delete node items
(routed through the shared useNodeActions hook) alongside the existing
Cordon item, so operators can manage node lifecycle without leaving the
Fleet page.

The card kebab is shown to admins regardless of tier; Cordon stays
Admiral-only. Delete is hidden on the local default node. After any
Add/Edit/Delete the Fleet overview refetches so the grid reflects the
change immediately.

* docs(fleet): document Add/Edit/Delete node actions on Fleet Overview

Updates the Action buttons table to cover the new Add node entry point
on the Fleet header, and adds a new Node actions menu section
describing the per-card Edit/Delete/Cordon items, their tier and
permission gating, and the auto connection test that fires after
saving a proxy-mode remote.
2026-05-15 19:54:23 -04:00

398 lines
21 KiB
TypeScript

import { useState } from 'react';
import {
Server, Cpu, MemoryStick, HardDrive, ChevronDown, ChevronRight,
Layers, Wifi, WifiOff, AlertTriangle, Download, Loader2,
MoreVertical, Ban, Pencil, Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ConfirmModal } from '@/components/ui/modal';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { formatBytes } from '@/lib/utils';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { formatVersion } from '@/lib/version';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
import { useNodes, type Node } from '@/context/NodeContext';
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
import { UpdateStatusBadge } from './UpdateStatusBadge';
import { StackSection } from './NodeCardStackList';
import type { Label as StackLabel } from '../label-types';
import type { FleetNode, NodeUpdateStatus } from './types';
import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from './nodeUtils';
// --- Types ---
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;
onCordonChange?: () => void;
onEdit?: (node: Node) => void;
onDelete?: (node: Node) => void;
}
// --- 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>
);
}
// --- Main Export ---
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete }: NodeCardProps) {
const [expanded, setExpanded] = useState(false);
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
const [loadingStacks, setLoadingStacks] = useState(false);
const [cordonModalOpen, setCordonModalOpen] = useState(false);
const [cordonReason, setCordonReason] = useState('');
const [cordonSubmitting, setCordonSubmitting] = useState(false);
const { isPaid, license } = useLicense();
const { isAdmin } = useAuth();
const { nodes: registryNodes } = useNodes();
const isAdmiral = isPaid && license?.variant === 'admiral';
const registryNode = registryNodes.find(n => n.id === node.id);
const canEdit = Boolean(isAdmin && onEdit && registryNode);
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default);
const canCordon = isAdmiral;
const showMenu = canEdit || canDelete || canCordon;
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 openCordonModal = () => {
setCordonReason('');
setCordonModalOpen(true);
};
const handleCordonConfirm = async () => {
setCordonSubmitting(true);
try {
if (node.cordoned) {
await uncordonNode(node.id);
toast.success(`Uncordoned ${node.name}`);
} else {
await cordonNode(node.id, cordonReason.trim() || null);
toast.success(`Cordoned ${node.name}`);
}
setCordonModalOpen(false);
onCordonChange?.();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update cordon state';
toast.error(message);
} finally {
setCordonSubmitting(false);
}
};
const handleExpand = async () => {
const next = !expanded;
setExpanded(next);
if (next && stacks === null) {
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 font-mono text-[9px] uppercase tracking-[0.22em] text-brand ${showMenu ? 'right-9' : 'right-3'}`}>
Local
</span>
)}
{showMenu && (
<div className="absolute top-2 right-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Node actions"
className="inline-flex items-center justify-center w-6 h-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<MoreVertical className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{canEdit && registryNode && (
<DropdownMenuItem onSelect={() => onEdit!(registryNode)}>
<Pencil className="w-3.5 h-3.5 mr-2" />
Edit node
</DropdownMenuItem>
)}
{canDelete && registryNode && (
<DropdownMenuItem
onSelect={() => onDelete!(registryNode)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="w-3.5 h-3.5 mr-2" />
Delete node
</DropdownMenuItem>
)}
{canCordon && (
<DropdownMenuItem onSelect={openCordonModal}>
<Ban className="w-3.5 h-3.5 mr-2" />
{node.cordoned ? 'Uncordon node' : 'Cordon node'}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<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>
)}
{node.cordoned && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4 shrink-0 bg-warning/15 text-warning border-warning/30"
title={node.cordoned_reason ?? 'Unschedulable: new blueprint deployments skip this node'}
>
<Ban className="w-2.5 h-2.5 mr-0.5" /> Cordoned
</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>
<ConfirmModal
open={cordonModalOpen}
onOpenChange={(open) => {
if (!cordonSubmitting) setCordonModalOpen(open);
}}
kicker="Federation"
title={node.cordoned ? `Uncordon ${node.name}` : `Cordon ${node.name}`}
description={node.cordoned
? 'Re-enable this node for new blueprint placements. Existing deployments are unchanged.'
: 'Mark this node as unschedulable. New blueprint deployments will skip it. Existing deployments remain in place.'}
confirmLabel={node.cordoned ? 'Uncordon node' : 'Cordon node'}
confirming={cordonSubmitting}
onConfirm={handleCordonConfirm}
>
{!node.cordoned && (
<div className="space-y-1.5">
<label htmlFor={`cordon-reason-${node.id}`} className="text-xs font-medium text-muted-foreground">
Reason (optional)
</label>
<input
id={`cordon-reason-${node.id}`}
type="text"
maxLength={256}
value={cordonReason}
onChange={(e) => setCordonReason(e.target.value)}
placeholder="e.g. draining for maintenance"
className="w-full h-8 px-2 text-sm rounded-md border border-input bg-background"
disabled={cordonSubmitting}
/>
</div>
)}
</ConfirmModal>
{/* Expandable Stack List with Container Drill-Down */}
{isOnline && (
<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>
);
}