Files
sencho/frontend/src/components/FleetView/NodeDetailsSheet.tsx
T
Anso 97be019696 feat(fleet): add Node details sheet to the node card kebab (#1752)
* feat(fleet): add Node details sheet to the node card kebab

Every Fleet node card now carries a "Node details" kebab item, open to
any role that can see the card (previously the kebab only rendered
for users with node-manage permissions, so plain viewers had none).
The sheet shows connectivity, live capacity, Compose workload,
version/capability compatibility, and governance info (labels, cordon
reason and date, default-node flag, Compose directory, registration
date) using data the Fleet page already fetches, plus one lazy call
to the existing node meta endpoint for capabilities. Wired into both
the desktop card and the mobile bespoke Fleet screen.

* fix(fleet): correct Node details sheet timestamp units and update-status fallback

QA against a live 3-node fleet found that last_successful_contact and
pilot_last_seen come back from the fleet-overview endpoint in Unix
seconds, but the sheet passed them straight into a milliseconds-only
formatter, rendering values like "20647d ago" instead of "just now".
Both are now converted before formatting.

The Compatibility section's update-status badge also fell through to
a confident "Up to date" whenever updateStatus was absent (e.g. on
mobile, which doesn't poll update status) instead of reflecting that
there was no data to back the claim; it now renders "Unknown" in that
case. The local node no longer shows a misleading "Last successful
contact: Never". Reworded the "read-only sheet" language in the docs
page to describe the sheet accurately, since the Governance section's
label picker stays editable for node managers by design.
2026-08-02 21:35:50 -04:00

351 lines
19 KiB
TypeScript

import { useEffect, useState, type ReactNode } from 'react';
import { Cpu, MemoryStick, HardDrive, Globe, Monitor, Terminal, Ban, Pencil, KeyRound, Network } from 'lucide-react';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { NodeLabelPicker } from '@/components/blueprints/NodeLabelPicker';
import { useNodes, type Node } from '@/context/NodeContext';
import { formatVersion } from '@/lib/version';
import { formatTimeAgo } from '@/lib/relativeTime';
import { formatBytes } from '@/lib/utils';
import { PinnedUpdateBadge } from './PinnedUpdateBadge';
import type { FleetNode, NodeUpdateStatus } from './types';
interface NodeDetailsSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
node: FleetNode | null;
registryNode: Node | null;
updateStatus?: NodeUpdateStatus;
networkingSignal?: { exposed: boolean; unknown: boolean; drift: boolean };
canManageNode: boolean;
onOpenNetworking?: (nodeId: number) => void;
onEdit?: (node: Node) => void;
}
// A small nice-to-have translation for the most operator-relevant capability
// strings; anything not listed here just renders its raw identifier.
const CAPABILITY_LABELS: Partial<Record<string, string>> = {
'cross-node-rbac': 'Cross-node RBAC',
'self-update': 'Self-update',
'fleet': 'Fleet management',
'compose-networking': 'Networking inventory',
};
function formatTimestamp(ms: number): string {
return new Date(ms).toLocaleString();
}
// `FleetNode.last_successful_contact` and `FleetNode.pilot_last_seen` come from
// the fleet-overview endpoint in Unix SECONDS (DatabaseService.updateNodeLastContact
// writes Math.floor(Date.now()/1000); fleet.ts's pilotLastSeenSeconds() divides the
// millisecond DB value by 1000 for this same response). `formatTimeAgo`/`formatTimestamp`
// both expect milliseconds, so any FleetNode-sourced timestamp must convert here before
// use. `registryNode`-sourced timestamps (e.g. pilot_last_seen from /api/nodes) are
// already in milliseconds and must NOT be passed through this helper.
function fleetSecondsToMs(seconds: number): number {
return seconds * 1000;
}
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 Field({ label, children, span }: { label: string; children: ReactNode; span?: 1 | 2 }) {
return (
<div className={span === 2 ? 'col-span-2' : undefined}>
<span className="text-xs text-muted-foreground">{label}</span>
{children}
</div>
);
}
export function NodeDetailsSheet({
open, onOpenChange, node, registryNode, updateStatus, networkingSignal,
canManageNode, onOpenNetworking, onEdit,
}: NodeDetailsSheetProps) {
const { nodeMeta, refreshNodeMeta } = useNodes();
const [capabilitiesExpanded, setCapabilitiesExpanded] = useState(false);
const nodeId = node?.id ?? null;
useEffect(() => {
if (open && nodeId !== null) void refreshNodeMeta(nodeId);
}, [open, nodeId, refreshNodeMeta]);
useEffect(() => {
if (!open) setCapabilitiesExpanded(false);
}, [open]);
if (!node) return null;
const meta = nodeMeta.get(node.id) ?? null;
const isLocal = node.type === 'local';
const isPilot = registryNode?.mode === 'pilot_agent';
const connectionModeLabel = isLocal ? 'Local' : isPilot ? 'Pilot Agent' : 'API Proxy';
const versionLabel = formatVersion(updateStatus?.version ?? meta?.version ?? null);
const cpuPercent = node.systemStats ? parseFloat(node.systemStats.cpu.usage) : 0;
const memPercent = node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0;
const diskPercent = node.systemStats?.disk ? parseFloat(node.systemStats.disk.usagePercent) : 0;
const hasNetworkingSignal = Boolean(
networkingSignal && (networkingSignal.exposed || networkingSignal.unknown || networkingSignal.drift),
);
const metaLine = [
connectionModeLabel,
node.status === 'online' ? 'Online' : node.status === 'offline' ? 'Offline' : 'Unknown',
versionLabel,
node.stacks ? `${node.stacks.length} stack${node.stacks.length === 1 ? '' : 's'}` : null,
].filter(Boolean).join(' · ');
const footerContext = node.status === 'online'
? 'Live · refreshes with the fleet overview'
: node.last_successful_contact
? `Last seen ${formatTimeAgo(fleetSecondsToMs(node.last_successful_contact))}`
: 'Never contacted';
return (
<SystemSheet
open={open}
onOpenChange={onOpenChange}
crumb={['Fleet', 'Node', node.name]}
name={node.name}
meta={metaLine}
primaryAction={canManageNode && onEdit && registryNode ? {
label: 'Edit node',
icon: Pencil,
onClick: () => onEdit(registryNode),
} : undefined}
secondaryActions={onOpenNetworking && hasNetworkingSignal ? [{
label: 'View networking',
icon: Network,
onClick: () => onOpenNetworking(node.id),
}] : undefined}
footerContext={footerContext}
size="md"
>
<SheetSection title="Connectivity">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<Field label="Connection mode">
<p className="text-xs mt-0.5 flex items-center gap-1.5">
{isLocal ? <Monitor className="w-3 h-3" strokeWidth={1.5} /> : isPilot ? <Terminal className="w-3 h-3" strokeWidth={1.5} /> : <Globe className="w-3 h-3" strokeWidth={1.5} />}
{connectionModeLabel}
</p>
</Field>
<Field label="Management endpoint">
<p className="font-mono text-xs mt-0.5 break-all">
{isLocal
? 'docker.sock'
: isPilot
? (registryNode?.pilot_last_seen ? `Tunnel (seen ${formatTimeAgo(registryNode.pilot_last_seen)})` : 'Tunnel (waiting)')
: (registryNode?.api_url || '-')}
</p>
</Field>
{typeof node.latency_ms === 'number' && (
<Field label="Latency">
<p className="font-mono text-xs mt-0.5 tabular-nums">{node.latency_ms} ms</p>
</Field>
)}
{!isLocal && (
<Field label="Last successful contact">
<p className="text-xs mt-0.5" title={node.last_successful_contact ? formatTimestamp(fleetSecondsToMs(node.last_successful_contact)) : undefined}>
{node.last_successful_contact ? formatTimeAgo(fleetSecondsToMs(node.last_successful_contact)) : 'Never'}
</p>
</Field>
)}
{isPilot && (
<>
<Field label="Pilot heartbeat">
<p className="text-xs mt-0.5">
{node.pilot_last_seen ? formatTimeAgo(fleetSecondsToMs(node.pilot_last_seen)) : 'Never'}
</p>
</Field>
<Field label="Pilot Agent version">
<p className="font-mono text-xs mt-0.5">{formatVersion(registryNode?.pilot_agent_version) ?? 'Unknown'}</p>
</Field>
</>
)}
<Field label="Token configured">
<p className="text-xs mt-0.5">
<Badge variant="outline" className="text-[10px] h-5 gap-1">
<KeyRound className="w-2.5 h-2.5" strokeWidth={1.5} />
{registryNode?.has_token ? 'Yes' : 'No'}
</Badge>
</p>
</Field>
</div>
</SheetSection>
<SheetSection title="Capacity">
{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 · {node.systemStats.cpu.cores} cores
</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" /> Memory
</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>
) : (
<p className="text-xs text-muted-foreground italic">Unavailable while the node is offline.</p>
)}
</SheetSection>
<SheetSection title={`Compose workload · ${node.stacks?.length ?? 0} stacks`}>
{node.stats ? (
<div className="grid grid-cols-4 rounded-md border border-card-border overflow-hidden text-center mb-2">
<div className="border-r border-card-border bg-card px-2 py-2">
<div className="text-base font-medium leading-none tabular-nums text-stat-value">{node.stats.active}</div>
<div className="text-[9px] leading-3 font-mono uppercase tracking-[0.16em] text-stat-subtitle mt-1">Running</div>
</div>
<div className="border-r border-card-border bg-card px-2 py-2">
<div className="text-base font-medium leading-none tabular-nums text-stat-value">{node.stats.exited}</div>
<div className="text-[9px] leading-3 font-mono uppercase tracking-[0.16em] text-stat-subtitle mt-1">Stopped</div>
</div>
<div className="border-r border-card-border bg-card px-2 py-2">
<div className="text-base font-medium leading-none tabular-nums text-stat-value">{node.stats.managed}</div>
<div className="text-[9px] leading-3 font-mono uppercase tracking-[0.16em] text-stat-subtitle mt-1">Managed</div>
</div>
<div className="bg-card px-2 py-2">
<div className="text-base font-medium leading-none tabular-nums text-stat-value">{node.stats.unmanaged}</div>
<div className="text-[9px] leading-3 font-mono uppercase tracking-[0.16em] text-stat-subtitle mt-1">Unmanaged</div>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground italic">Unavailable while the node is offline.</p>
)}
{onOpenNetworking && hasNetworkingSignal && networkingSignal && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-5 cursor-pointer bg-warning/10 text-warning border-warning/30 hover:bg-warning/20"
onClick={() => onOpenNetworking(node.id)}
>
Networking · {networkingSignal.drift ? 'drift' : networkingSignal.exposed ? 'exposed' : 'unknown exposure'}
</Badge>
)}
</SheetSection>
<SheetSection title="Compatibility">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm mb-3">
<Field label="Sencho version">
<p className="font-mono text-xs mt-0.5">{versionLabel ?? 'Unknown'}</p>
</Field>
<Field label="Image channel">
<p className="text-xs mt-0.5 capitalize">{updateStatus?.imageChannel ?? 'Unknown'}</p>
</Field>
<Field label="Pin type">
<p className="text-xs mt-0.5 capitalize">{updateStatus?.imagePinKind ?? 'Unknown'}</p>
</Field>
<Field label="Update status">
<p className="text-xs mt-0.5">
{!updateStatus ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5 text-muted-foreground">Unknown</Badge>
) : updateStatus.updateBlocked ? (
<PinnedUpdateBadge reason={updateStatus.updateBlockedReason} className="text-[10px] px-1.5 py-0 h-5" />
) : updateStatus.updateAvailable ? (
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-warning/15 text-warning border-warning/30">Update available</Badge>
) : (
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-success-muted text-success border-success/30">Up to date</Badge>
)}
</p>
</Field>
</div>
{meta ? (
<div>
<button
type="button"
onClick={() => setCapabilitiesExpanded(v => !v)}
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline"
>
{meta.capabilities.length} capabilities advertised {capabilitiesExpanded ? '(hide)' : '(show)'}
</button>
{capabilitiesExpanded && (
<ul className="mt-2 flex flex-wrap gap-1">
{meta.capabilities.map(c => (
<li key={c}>
<Badge variant="outline" className="text-[10px] h-5 font-mono">{CAPABILITY_LABELS[c] ?? c}</Badge>
</li>
))}
</ul>
)}
</div>
) : (
<Skeleton className="h-4 w-40" />
)}
</SheetSection>
<SheetSection title="Governance">
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground">Labels</span>
<div className="mt-1">
<NodeLabelPicker nodeId={node.id} canEdit={canManageNode} />
</div>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<Field label="Cordon status">
<p className="text-xs mt-0.5">
{node.cordoned ? (
<Badge variant="outline" className="text-[10px] h-5 gap-1 bg-warning/15 text-warning border-warning/30">
<Ban className="w-2.5 h-2.5" strokeWidth={1.5} /> Cordoned
</Badge>
) : (
<span className="text-muted-foreground">Schedulable</span>
)}
</p>
</Field>
{node.cordoned && (
<>
<Field label="Cordoned since">
<p className="text-xs mt-0.5">{node.cordoned_at ? formatTimestamp(node.cordoned_at) : 'Unknown'}</p>
</Field>
<Field label="Reason" span={2}>
<p className="text-xs mt-0.5">{node.cordoned_reason ?? 'No reason given'}</p>
</Field>
</>
)}
<Field label="Default node">
<p className="text-xs mt-0.5">{registryNode?.is_default ? 'Yes' : 'No'}</p>
</Field>
<Field label="Compose directory">
<p className="font-mono text-xs mt-0.5 break-all">{registryNode?.compose_dir ?? '-'}</p>
</Field>
<Field label="Registered" span={2}>
<p className="text-xs mt-0.5">{registryNode?.created_at ? formatTimestamp(registryNode.created_at) : 'Unknown'}</p>
</Field>
</div>
</div>
</SheetSection>
</SystemSheet>
);
}