mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
f83f01417c
This reverts commit 9e1d97d39e.
430 lines
18 KiB
TypeScript
430 lines
18 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
import {
|
|
ReactFlow,
|
|
Background,
|
|
Controls,
|
|
MiniMap,
|
|
useNodesState,
|
|
useEdgesState,
|
|
Handle,
|
|
Position,
|
|
type Node,
|
|
type Edge,
|
|
type NodeTypes,
|
|
} from '@xyflow/react';
|
|
import '@xyflow/react/dist/style.css';
|
|
import { Server, Zap, Network, Layers, Move, Ban, Clock, Activity } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
layoutFleetGraph,
|
|
type FleetNodeData,
|
|
type FleetTopologyNode,
|
|
type LayoutMode,
|
|
type SavedPositions,
|
|
} from '@/lib/fleet-topology-layout';
|
|
import { NodeLabelPill } from '@/components/blueprints/NodeLabelPill';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
|
|
interface FleetTopologyProps {
|
|
nodes: FleetTopologyNode[];
|
|
onNodeClick?: (nodeId: number) => void;
|
|
mode: LayoutMode;
|
|
onModeChange: (mode: LayoutMode) => void;
|
|
savedPositions: SavedPositions;
|
|
onPositionsChange: (positions: SavedPositions) => void;
|
|
}
|
|
|
|
// Raw oklch values for MiniMap coloring (ReactFlow cannot resolve CSS vars
|
|
// inside inline styles / SVG fills).
|
|
const MINIMAP_BRAND = 'oklch(0.78 0.11 195)';
|
|
const MINIMAP_WARNING = 'oklch(0.75 0.14 75)';
|
|
const MINIMAP_MUTED = 'oklch(0.55 0 0)';
|
|
|
|
const MAX_INLINE_LABELS = 3;
|
|
const PILOT_STALE_MS = 60_000;
|
|
|
|
function dotClass(node: FleetTopologyNode): string {
|
|
if (node.status !== 'online') return 'bg-destructive';
|
|
if (node.critical) return 'bg-warning';
|
|
return 'bg-success';
|
|
}
|
|
|
|
function barColor(value: number): string {
|
|
if (value >= 85) return 'bg-destructive';
|
|
if (value >= 60) return 'bg-warning';
|
|
return 'bg-brand';
|
|
}
|
|
|
|
function MetricBar({ label, value, muted }: { label: string; value: number; muted: boolean }) {
|
|
const clamped = Math.max(0, Math.min(100, value));
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-9 font-mono text-[9px] uppercase tracking-[0.2em] text-muted-foreground">
|
|
{label}
|
|
</span>
|
|
<div className="flex-1 h-1 rounded-full bg-muted/60 overflow-hidden">
|
|
<div
|
|
className={cn('h-full rounded-full transition-[width] duration-300', muted ? 'bg-muted-foreground/40' : barColor(value))}
|
|
style={{ width: `${clamped}%` }}
|
|
/>
|
|
</div>
|
|
<span className="w-8 text-right font-mono text-[10px] tabular-nums text-stat-value">
|
|
{Math.round(clamped)}%
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatRelative(timestamp: number): string {
|
|
const diff = Date.now() - timestamp;
|
|
if (diff < 60_000) return `${Math.round(diff / 1000)}s ago`;
|
|
if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`;
|
|
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`;
|
|
return `${Math.round(diff / 86_400_000)}d ago`;
|
|
}
|
|
|
|
function FleetNodeCard({ data, selected }: { data: FleetNodeData; selected?: boolean }) {
|
|
const node = data.node;
|
|
const isLocal = node.type === 'local';
|
|
const isOffline = node.status !== 'online';
|
|
const stackLabel = node.stackCount === 1 ? 'stack' : 'stacks';
|
|
const labels = node.labels ?? [];
|
|
const visibleLabels = labels.slice(0, MAX_INLINE_LABELS);
|
|
const overflowLabels = labels.length - visibleLabels.length;
|
|
const cordoned = Boolean(node.cordoned);
|
|
const pilotStale = !isOffline
|
|
&& node.nodeMode === 'pilot_agent'
|
|
&& typeof node.pilotLastSeen === 'number'
|
|
&& Date.now() - node.pilotLastSeen > PILOT_STALE_MS;
|
|
const showLatency = !isLocal && !isOffline && typeof node.latencyMs === 'number';
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'w-[240px] rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors overflow-hidden',
|
|
'hover:border-t-card-border-hover cursor-pointer',
|
|
isLocal && 'ring-1 ring-brand/40',
|
|
isOffline && 'opacity-70',
|
|
selected && 'ring-1 ring-brand',
|
|
)}
|
|
>
|
|
<Handle type="target" position={Position.Left} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
|
|
|
{cordoned && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="flex items-center gap-1.5 px-3 py-1 bg-warning/15 border-b border-warning/30">
|
|
<Ban className="h-3 w-3 text-warning" strokeWidth={2} />
|
|
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-warning">
|
|
Cordoned
|
|
</span>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
{node.cordonedReason || 'Cordoned: scheduling paused.'}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-card-border">
|
|
<span aria-hidden="true" className={cn('h-2 w-2 rounded-full shrink-0', dotClass(node))} />
|
|
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">
|
|
{isOffline ? 'Offline' : node.critical ? 'Critical' : 'Online'}
|
|
</span>
|
|
<span className="ml-auto flex items-center gap-1.5">
|
|
{pilotStale && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Clock className="h-3 w-3 text-warning" strokeWidth={2} aria-label="Pilot heartbeat stale" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
{node.pilotLastSeen
|
|
? `Pilot heartbeat ${formatRelative(node.pilotLastSeen)}`
|
|
: 'Pilot heartbeat stale'}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
{node.critical && !isOffline ? (
|
|
<Zap className="h-3 w-3 text-warning" strokeWidth={2} aria-label="Critical" />
|
|
) : null}
|
|
<span className={cn(
|
|
'font-mono text-[9px] uppercase tracking-[0.22em]',
|
|
isLocal ? 'text-brand' : 'text-muted-foreground',
|
|
)}>
|
|
{isLocal ? 'Local' : 'Remote'}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-card-border">
|
|
<Server className="h-3.5 w-3.5 text-stat-icon shrink-0" strokeWidth={1.5} />
|
|
<span className="text-xs font-medium text-stat-value truncate">{node.name}</span>
|
|
</div>
|
|
|
|
{labels.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 px-3 py-1.5 border-b border-card-border">
|
|
{visibleLabels.map(l => (
|
|
<NodeLabelPill key={l} label={l} size="sm" />
|
|
))}
|
|
{overflowLabels > 0 && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex items-center rounded-md border border-card-border bg-muted/40 px-1.5 py-0 font-mono text-[10px] text-muted-foreground">
|
|
+{overflowLabels}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
{labels.slice(MAX_INLINE_LABELS).join(', ')}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="px-3 py-2 space-y-1 border-b border-card-border">
|
|
<MetricBar label="CPU" value={node.cpuPercent} muted={isOffline} />
|
|
<MetricBar label="MEM" value={node.memPercent} muted={isOffline} />
|
|
<MetricBar label="DISK" value={node.diskPercent} muted={isOffline} />
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between px-3 py-1.5 font-mono text-[10px] tabular-nums text-muted-foreground">
|
|
<span>{node.stackCount} {stackLabel} · {node.runningCount} running</span>
|
|
{showLatency && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Activity className="h-2.5 w-2.5" strokeWidth={2} />
|
|
{Math.round(node.latencyMs!)}ms
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<Handle type="source" position={Position.Right} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const nodeTypes: NodeTypes = {
|
|
fleetNode: FleetNodeCard,
|
|
};
|
|
|
|
interface ModeButtonSpec {
|
|
mode: LayoutMode;
|
|
label: string;
|
|
description: string;
|
|
icon: typeof Network;
|
|
}
|
|
|
|
const MODE_BUTTONS: ModeButtonSpec[] = [
|
|
{ mode: 'hub', label: 'Hub', description: 'Local node at the centre, remotes radiating out.', icon: Network },
|
|
{ mode: 'grouped', label: 'Grouped', description: 'Cluster nodes by their primary label.', icon: Layers },
|
|
{ mode: 'free', label: 'Free', description: 'Drag nodes anywhere; positions persist in this browser.', icon: Move },
|
|
];
|
|
|
|
function TopologyToolbar({
|
|
mode,
|
|
onModeChange,
|
|
}: {
|
|
mode: LayoutMode;
|
|
onModeChange: (mode: LayoutMode) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-card-border bg-card/60">
|
|
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground mr-1">
|
|
Layout
|
|
</span>
|
|
{MODE_BUTTONS.map(spec => {
|
|
const Icon = spec.icon;
|
|
const active = mode === spec.mode;
|
|
return (
|
|
<TooltipProvider key={spec.mode}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => onModeChange(spec.mode)}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-mono uppercase tracking-[0.12em] transition-colors',
|
|
active
|
|
? 'border-brand/60 bg-brand/15 text-brand'
|
|
: 'border-card-border text-muted-foreground hover:text-foreground hover:bg-muted/40',
|
|
)}
|
|
>
|
|
<Icon className="h-3 w-3" strokeWidth={2} />
|
|
{spec.label}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
{spec.description}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FleetTopology({
|
|
nodes: fleetNodes,
|
|
onNodeClick,
|
|
mode,
|
|
onModeChange,
|
|
savedPositions,
|
|
onPositionsChange,
|
|
}: FleetTopologyProps) {
|
|
const onNodeClickRef = useRef(onNodeClick);
|
|
onNodeClickRef.current = onNodeClick;
|
|
|
|
const onPositionsChangeRef = useRef(onPositionsChange);
|
|
onPositionsChangeRef.current = onPositionsChange;
|
|
|
|
const savedPositionsRef = useRef(savedPositions);
|
|
savedPositionsRef.current = savedPositions;
|
|
|
|
const effectiveMode: LayoutMode = mode;
|
|
|
|
// Only re-layout when the topology *shape* changes (nodes added/removed,
|
|
// type/status/label flips, mode flips). Metric value changes alone must
|
|
// not snap user-dragged nodes back to dagre-computed positions on every
|
|
// poll.
|
|
const shapeKey = useMemo(
|
|
() => effectiveMode + '|' + fleetNodes
|
|
.map(n => `${n.id}:${n.type}:${n.status}:${(n.labels ?? []).slice().sort().join(',')}`)
|
|
.sort()
|
|
.join('|'),
|
|
[fleetNodes, effectiveMode],
|
|
);
|
|
|
|
const [flowNodes, setFlowNodes, onNodesChange] = useNodesState<Node>([]);
|
|
const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
|
|
useEffect(() => {
|
|
const { nodes: nextNodes, edges: nextEdges } = layoutFleetGraph(fleetNodes, {
|
|
mode: effectiveMode,
|
|
savedPositions: savedPositionsRef.current,
|
|
});
|
|
setFlowNodes(nextNodes);
|
|
setFlowEdges(nextEdges);
|
|
// fleetNodes is referenced via shapeKey; savedPositions is read from
|
|
// a ref so persisted drags don't trigger a relayout cycle.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [shapeKey, setFlowNodes, setFlowEdges]);
|
|
|
|
// Update live metrics on every poll without resetting positions.
|
|
useEffect(() => {
|
|
setFlowNodes(current => current.map(flowNode => {
|
|
const next = fleetNodes.find(n => String(n.id) === flowNode.id);
|
|
if (!next) return flowNode;
|
|
const existingData = flowNode.data as FleetNodeData | undefined;
|
|
const existing = existingData?.node;
|
|
if (existing && existing.cpuPercent === next.cpuPercent
|
|
&& existing.memPercent === next.memPercent
|
|
&& existing.diskPercent === next.diskPercent
|
|
&& existing.stackCount === next.stackCount
|
|
&& existing.runningCount === next.runningCount
|
|
&& existing.critical === next.critical
|
|
&& existing.cordoned === next.cordoned
|
|
&& existing.cordonedReason === next.cordonedReason
|
|
&& existing.latencyMs === next.latencyMs
|
|
&& existing.pilotLastSeen === next.pilotLastSeen) {
|
|
return flowNode;
|
|
}
|
|
return {
|
|
...flowNode,
|
|
data: { node: next, clusterLabel: existingData?.clusterLabel } satisfies FleetNodeData,
|
|
};
|
|
}));
|
|
}, [fleetNodes, setFlowNodes]);
|
|
|
|
const handleNodeClick = useCallback((_event: React.MouseEvent, flowNode: Node) => {
|
|
const id = Number(flowNode.id);
|
|
if (!Number.isNaN(id)) {
|
|
onNodeClickRef.current?.(id);
|
|
}
|
|
}, []);
|
|
|
|
const handleNodeDragStop = useCallback((_event: MouseEvent | TouchEvent, _node: Node, allDragged: Node[]) => {
|
|
if (effectiveMode !== 'free') return;
|
|
setFlowNodes(current => {
|
|
const dragged = new Map(allDragged.map(n => [n.id, n.position]));
|
|
const validIds = new Set(current.map(n => n.id));
|
|
const merged: SavedPositions = {};
|
|
for (const n of current) {
|
|
const pos = dragged.get(n.id) ?? n.position;
|
|
merged[n.id] = { x: pos.x, y: pos.y };
|
|
}
|
|
// Drop any stale saved entries for nodes no longer present.
|
|
for (const key of Object.keys(savedPositionsRef.current)) {
|
|
if (!validIds.has(key)) delete merged[key];
|
|
}
|
|
onPositionsChangeRef.current(merged);
|
|
return current;
|
|
});
|
|
}, [effectiveMode, setFlowNodes]);
|
|
|
|
const miniMapNodeColor = useCallback((n: Node) => {
|
|
const data = n.data as FleetNodeData | undefined;
|
|
const topo = data?.node;
|
|
if (!topo || topo.status !== 'online') return MINIMAP_MUTED;
|
|
if (topo.critical) return MINIMAP_WARNING;
|
|
return MINIMAP_BRAND;
|
|
}, []);
|
|
|
|
if (fleetNodes.length === 0) {
|
|
return (
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-10 text-center">
|
|
<p className="text-sm text-muted-foreground">No nodes to plot.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const allRemotesUnlabeled = effectiveMode === 'grouped'
|
|
&& fleetNodes.filter(n => n.type !== 'local').every(n => (n.labels ?? []).length === 0);
|
|
|
|
return (
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
|
<TopologyToolbar mode={effectiveMode} onModeChange={onModeChange} />
|
|
{allRemotesUnlabeled && (
|
|
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/30 border-b border-card-border">
|
|
No node labels assigned. Add labels in Settings · Nodes to see remotes cluster.
|
|
</div>
|
|
)}
|
|
<div className="h-[560px] w-full">
|
|
<ReactFlow
|
|
nodes={flowNodes}
|
|
edges={flowEdges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onNodeClick={handleNodeClick}
|
|
onNodeDragStop={handleNodeDragStop}
|
|
nodeTypes={nodeTypes}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.2 }}
|
|
proOptions={{ hideAttribution: true }}
|
|
nodesConnectable={false}
|
|
className="bg-background"
|
|
>
|
|
<Background gap={20} size={1} className="opacity-30" />
|
|
<Controls
|
|
className="!bg-card !border-card-border !shadow-card-bevel [&>button]:!bg-card [&>button]:!border-card-border [&>button]:!text-foreground [&>button:hover]:!bg-muted"
|
|
showInteractive={false}
|
|
/>
|
|
<MiniMap
|
|
className="!bg-card !border-card-border !shadow-card-bevel"
|
|
nodeColor={miniMapNodeColor}
|
|
maskColor="oklch(0 0 0 / 0.2)"
|
|
pannable
|
|
zoomable
|
|
/>
|
|
</ReactFlow>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|