mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
97be019696
* 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.
351 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|