From 2e82eb44fec48c33c73cd10e030c4b27010b4824 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 15 May 2026 08:59:52 -0400 Subject: [PATCH] feat(fleet): multi-mode topology with label grouping and persisted positions (#1054) The Topology view now offers three layouts so the canvas adapts to how the fleet is organised, not the other way around: - Hub: the gateway anchored on the left with remotes radiating right - Grouped (Skipper+): remotes cluster by their primary node label, with the local node in its own cluster and unlabeled remotes in an Unlabeled cluster - Free (Skipper+): drag any node; positions persist per browser via local storage Node cards gain label pills (Skipper+), a cordon banner with reason tooltip, a latency chip for online remotes, and a stale pilot-heartbeat glyph. All fields are sourced from /api/fleet/overview, which already returns cordon, latency, and pilot timestamps; no backend changes required. Community continues to see the single Hub layout (without the toolbar, no gating cues), matching the visibility principle that paid affordances are hidden from lower tiers rather than displayed as locked teasers. The backend /api/node-labels route is already requirePaid, so the data gate is honoured end to end. Includes unit tests for the layout module (hub/grouped/free coverage) and the preferences hook (round-trip plus corrupt-JSON fallback). --- docs/features/fleet-view.mdx | 28 +- frontend/src/components/FleetView.tsx | 7 + .../src/components/FleetView/OverviewTab.tsx | 17 +- .../FleetView/hooks/useFleetOverview.ts | 13 +- .../FleetView/hooks/useNodeLabels.ts | 71 +++++ frontend/src/components/FleetView/types.ts | 4 + .../src/components/fleet/FleetTopology.tsx | 242 +++++++++++++++++- .../__tests__/useTopologyPreferences.test.tsx | 71 +++++ frontend/src/hooks/useTopologyPreferences.ts | 75 ++++++ .../__tests__/fleet-topology-layout.test.ts | 137 ++++++++++ frontend/src/lib/fleet-topology-layout.ts | 143 ++++++++++- 11 files changed, 781 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/FleetView/hooks/useNodeLabels.ts create mode 100644 frontend/src/hooks/__tests__/useTopologyPreferences.test.tsx create mode 100644 frontend/src/hooks/useTopologyPreferences.ts create mode 100644 frontend/src/lib/__tests__/fleet-topology-layout.test.ts diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 37144d7c..769d7752 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -33,12 +33,24 @@ The top of the page is a status masthead that summarises the state of the entire Below the masthead, switch between two layouts: - **Grid** (default): one card per node. The local node is pinned at the top with a cyan accent rail and a ★ Local badge so it is never confused with a remote. -- **Topology**: an interactive hub-and-spoke diagram with the local node on the left and remotes radiating to the right. Each node renders as a rack card with a status pill (Online, Critical, or Offline), a Local or Remote badge, CPU, memory, and disk bars, and a stack and running-container summary. The local node is framed with a cyan ring; critical nodes show a lightning icon; offline nodes are muted. Connector lines colour by link health (cyan when online, amber when critical, dashed when offline). Pan, zoom, drag nodes, and use the minimap to navigate large fleets. Click a node to jump to its details. +- **Topology**: an interactive node-map view. Each node renders as a rack card with a status pill (Online, Critical, or Offline), a Local or Remote badge, CPU, memory, and disk bars, and a stack and running-container summary. Online remotes also show their gateway round-trip latency; cordoned nodes carry an amber **Cordoned** banner; pilot-agent nodes whose heartbeat has gone stale flag a small clock glyph. The local node is framed with a cyan ring. Connector lines colour by link health (cyan when online, amber when critical, dashed when offline). Pan, zoom, and use the minimap to navigate large fleets. Click a node to jump to its details. Fleet Topology view showing the local node connected to a remote, each rendered as a rack card with CPU, memory, and disk bars +#### Topology layout modes (Skipper+) + +A toolbar at the top of the topology canvas offers three layouts. Pick the one that matches how you reason about your fleet. + +| Mode | What it does | +|------|--------------| +| **Hub** | Local node on the left; remotes radiate to the right. Best when you have a handful of nodes and want the gateway anchored. | +| **Grouped** | Remotes cluster by their primary node label (alphabetically first when a node has multiple). The local node sits in its own cluster. Use this to read environment, region, or role groupings at a glance. Nodes without any label fall into an Unlabeled cluster. | +| **Free** | Drag any node anywhere on the canvas. Positions persist in this browser, so the arrangement is there when you come back. | + +Assign labels in **Settings → Nodes** to drive Grouped mode. Each node accepts multiple labels; the alphabetically first one is its cluster. + ### Tabs The Fleet page has three tabs: @@ -262,3 +274,17 @@ This means the gateway could not connect to the remote node's `/api/meta` endpoi ### Update button returns "Admin access required" Fleet update operations (individual updates, Update All) require an admin account. If you see a 403 error when clicking **Update**, ask your Sencho administrator to either grant you admin privileges or perform the update on your behalf. + +### Free-mode topology positions don't persist + +Free mode stores node positions in your browser's local storage. The arrangement is per-browser, not synced across devices or users. If positions reset on reload, common causes are: + +- **Browser local storage is disabled** (private/incognito windows, strict tracking-prevention modes). Sencho falls back to in-memory state for the session, but nothing carries over. +- **Storage was cleared** by browser cleanup tools, profile reset, or by manually clearing site data. +- **A different browser or profile** is being used. Each browser keeps its own arrangement. + +To verify storage is writable, open DevTools → Application → Local Storage on the Sencho origin and confirm the `sencho-topology-preferences` key exists after switching modes or dragging a node. + +### Grouped topology shows everything in one Unlabeled cluster + +Grouped mode clusters remote nodes by their primary node label. When no remotes carry any labels, every remote falls into the **Unlabeled** cluster and the canvas looks similar to Hub mode. A hint banner above the canvas points to **Settings → Nodes** where labels are managed. Add at least one label to two or more remotes and reopen the topology to see the clusters split. diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index d93974c2..bc4cb7aa 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -12,6 +12,7 @@ import { useFleetPreferences } from './FleetView/hooks/useFleetPreferences'; import { useFleetUpdateStatus } from './FleetView/hooks/useFleetUpdateStatus'; import { useFleetPolling } from './FleetView/hooks/useFleetPolling'; import { useFleetOverview } from './FleetView/hooks/useFleetOverview'; +import { useTopologyPreferences } from '@/hooks/useTopologyPreferences'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; import { springs } from '@/lib/motion'; @@ -36,6 +37,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { const { prefs, updatePrefs } = useFleetPreferences(); const updateStatus = useFleetUpdateStatus(); const overview = useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses }); + const topology = useTopologyPreferences(); useFleetPolling({ fetchOverview: overview.fetchOverview, @@ -162,6 +164,11 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { onRetryUpdate={updateStatus.retryNodeUpdate} onDismissUpdate={updateStatus.dismissNodeUpdate} onCordonChange={() => { void overview.fetchOverview(true); }} + isPaid={isPaid} + topologyMode={topology.prefs.mode} + onTopologyModeChange={topology.setMode} + topologyPositions={topology.prefs.positions} + onTopologyPositionsChange={topology.setPositions} /> diff --git a/frontend/src/components/FleetView/OverviewTab.tsx b/frontend/src/components/FleetView/OverviewTab.tsx index b4d0d2ba..cf55e329 100644 --- a/frontend/src/components/FleetView/OverviewTab.tsx +++ b/frontend/src/components/FleetView/OverviewTab.tsx @@ -4,7 +4,7 @@ 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 { FleetTopologyNode, LayoutMode, SavedPositions } from '@/lib/fleet-topology-layout'; import type { Label as StackLabel } from '../label-types'; import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './types'; @@ -32,6 +32,11 @@ interface OverviewTabProps { onRetryUpdate?: (nodeId: number) => void; onDismissUpdate?: (nodeId: number) => void; onCordonChange?: () => void; + isPaid: boolean; + topologyMode: LayoutMode; + onTopologyModeChange: (mode: LayoutMode) => void; + topologyPositions: SavedPositions; + onTopologyPositionsChange: (positions: SavedPositions) => void; } export function OverviewTab({ @@ -58,6 +63,11 @@ export function OverviewTab({ onRetryUpdate, onDismissUpdate, onCordonChange, + isPaid, + topologyMode, + onTopologyModeChange, + topologyPositions, + onTopologyPositionsChange, }: OverviewTabProps) { return ( <> @@ -106,6 +116,11 @@ export function OverviewTab({ onNavigateToNode(id, '')} + isPaid={isPaid} + mode={topologyMode} + onModeChange={onTopologyModeChange} + savedPositions={topologyPositions} + onPositionsChange={onTopologyPositionsChange} /> ) : processedNodes.length > 0 ? (
diff --git a/frontend/src/components/FleetView/hooks/useFleetOverview.ts b/frontend/src/components/FleetView/hooks/useFleetOverview.ts index 53e4d197..10c6a0ac 100644 --- a/frontend/src/components/FleetView/hooks/useFleetOverview.ts +++ b/frontend/src/components/FleetView/hooks/useFleetOverview.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useMemo, useRef } from 'react'; import { apiFetch } from '@/lib/api'; import { useFleetLabels, labelPaletteKey } from './useFleetLabels'; +import { useNodeLabels } from './useNodeLabels'; import { isCritical, getNodeCpu, getNodeMem, getNodeDisk } from '../nodeUtils'; import type { FleetNode, ViewMode, FleetPreferences, NodeUpdateStatus } from '../types'; @@ -34,6 +35,7 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }: const abortRef = useRef(null); const { fleetPalette, fleetStackLabelMap } = useFleetLabels({ isPaid, nodes }); + const { labelsByNodeId, distinctLabels, isAvailable: nodeLabelsAvailable } = useNodeLabels({ isPaid, nodes }); const fetchOverview = useCallback(async (showRefresh = false) => { abortRef.current?.abort(); @@ -164,8 +166,14 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }: stackCount: n.stacks?.length ?? 0, runningCount: n.stats?.active ?? 0, critical: n.status === 'online' && isCritical(n), + labels: labelsByNodeId[n.id] ?? [], + cordoned: n.cordoned, + cordonedReason: n.cordoned_reason, + latencyMs: n.latency_ms ?? null, + pilotLastSeen: n.pilot_last_seen ?? null, + nodeMode: n.mode ?? null, })), - [processedNodes] + [processedNodes, labelsByNodeId] ); const allNodes = useMemo( () => (localNode ? [localNode, ...remoteNodes] : remoteNodes), @@ -207,6 +215,9 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }: updateStatusMap, fleetPalette, fleetStackLabelMap, + labelsByNodeId, + distinctNodeLabels: distinctLabels, + nodeLabelsAvailable, activeFilterCount, clearFilters, }; diff --git a/frontend/src/components/FleetView/hooks/useNodeLabels.ts b/frontend/src/components/FleetView/hooks/useNodeLabels.ts new file mode 100644 index 00000000..75c901fe --- /dev/null +++ b/frontend/src/components/FleetView/hooks/useNodeLabels.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import type { FleetNode } from '../types'; + +export interface UseNodeLabelsResult { + labelsByNodeId: Record; + distinctLabels: string[]; + isAvailable: boolean; +} + +interface UseNodeLabelsOptions { + isPaid: boolean; + nodes: FleetNode[]; +} + +export function useNodeLabels({ isPaid, nodes }: UseNodeLabelsOptions): UseNodeLabelsResult { + const [labelsByNodeId, setLabelsByNodeId] = useState>({}); + + const fetchLabels = useCallback(async () => { + if (!isPaid) { + setLabelsByNodeId({}); + return; + } + try { + const res = await apiFetch('/node-labels', { + localOnly: true, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) { + setLabelsByNodeId({}); + return; + } + const data = await res.json() as Record; + // Backend returns numeric keys as strings in JSON; normalize. + const map: Record = {}; + for (const [k, v] of Object.entries(data)) { + const id = Number(k); + if (!Number.isNaN(id) && Array.isArray(v)) map[id] = v; + } + setLabelsByNodeId(map); + } catch { + setLabelsByNodeId({}); + } + }, [isPaid]); + + // Refetch only when the set of node ids actually changes, not on every poll + // (poll mints a fresh nodes array reference). + const nodeIdKey = useMemo( + () => nodes.map(n => n.id).sort((a, b) => a - b).join(','), + [nodes], + ); + + useEffect(() => { + if (!isPaid) { + setLabelsByNodeId({}); + return; + } + void fetchLabels(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isPaid, nodeIdKey, fetchLabels]); + + const distinctLabels = useMemo(() => { + const set = new Set(); + for (const list of Object.values(labelsByNodeId)) { + for (const l of list) set.add(l); + } + return Array.from(set).sort((a, b) => a.localeCompare(b)); + }, [labelsByNodeId]); + + return { labelsByNodeId, distinctLabels, isAvailable: isPaid }; +} diff --git a/frontend/src/components/FleetView/types.ts b/frontend/src/components/FleetView/types.ts index f68f13d2..f9701188 100644 --- a/frontend/src/components/FleetView/types.ts +++ b/frontend/src/components/FleetView/types.ts @@ -18,6 +18,7 @@ export interface FleetNode { id: number; name: string; type: 'local' | 'remote'; + mode?: string; status: 'online' | 'offline' | 'unknown'; stats: FleetNodeStats | null; systemStats: FleetNodeSystemStats | null; @@ -25,6 +26,9 @@ export interface FleetNode { cordoned: boolean; cordoned_at: number | null; cordoned_reason: string | null; + latency_ms?: number; + last_successful_contact?: number | null; + pilot_last_seen?: number | null; } export interface NodeUpdateStatus { diff --git a/frontend/src/components/fleet/FleetTopology.tsx b/frontend/src/components/fleet/FleetTopology.tsx index 36055018..7fe0fb5f 100644 --- a/frontend/src/components/fleet/FleetTopology.tsx +++ b/frontend/src/components/fleet/FleetTopology.tsx @@ -13,17 +13,26 @@ import { type NodeTypes, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { Server, Zap } from 'lucide-react'; +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; + isPaid: boolean; + mode: LayoutMode; + onModeChange: (mode: LayoutMode) => void; + savedPositions: SavedPositions; + onPositionsChange: (positions: SavedPositions) => void; } // Raw oklch values for MiniMap coloring (ReactFlow cannot resolve CSS vars @@ -32,6 +41,9 @@ 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'; @@ -64,16 +76,33 @@ function MetricBar({ label, value, muted }: { label: string; value: number; mute ); } +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 (
+ {cordoned && ( + + + +
+ + + Cordoned + +
+
+ + {node.cordonedReason || 'Cordoned: scheduling paused.'} + +
+
+ )} +
+ {labels.length > 0 && ( +
+ {visibleLabels.map(l => ( + + ))} + {overflowLabels > 0 && ( + + + + + +{overflowLabels} + + + + {labels.slice(MAX_INLINE_LABELS).join(', ')} + + + + )} +
+ )} +
-
- {node.stackCount} {stackLabel} · {node.runningCount} running +
+ {node.stackCount} {stackLabel} · {node.runningCount} running + {showLatency && ( + + + {Math.round(node.latencyMs!)}ms + + )}
@@ -124,26 +213,114 @@ const nodeTypes: NodeTypes = { fleetNode: FleetNodeCard, }; -export function FleetTopology({ nodes: fleetNodes, onNodeClick }: FleetTopologyProps) { +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 ( +
+ + Layout + + {MODE_BUTTONS.map(spec => { + const Icon = spec.icon; + const active = mode === spec.mode; + return ( + + + + + + + {spec.description} + + + + ); + })} +
+ ); +} + +export function FleetTopology({ + nodes: fleetNodes, + onNodeClick, + isPaid, + 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; + + // Defensive: a Community user who somehow lands in a paid mode (older + // localStorage value, manual edit) gets snapped back to Hub. The toolbar + // also hides these modes for them, so this is a belt-and-suspenders guard. + const effectiveMode: LayoutMode = useMemo(() => { + if ((mode === 'grouped' || mode === 'free') && !isPaid) return 'hub'; + return mode; + }, [mode, isPaid]); + // Only re-layout when the topology *shape* changes (nodes added/removed, - // type or status flips). Metric value changes alone must not snap - // user-dragged nodes back to the dagre-computed positions on every poll. + // 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( - () => fleetNodes.map(n => `${n.id}:${n.type}:${n.status}`).sort().join('|'), - [fleetNodes], + () => effectiveMode + '|' + fleetNodes + .map(n => `${n.id}:${n.type}:${n.status}:${(n.labels ?? []).slice().sort().join(',')}`) + .sort() + .join('|'), + [fleetNodes, effectiveMode], ); const [flowNodes, setFlowNodes, onNodesChange] = useNodesState([]); const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState([]); useEffect(() => { - const { nodes: nextNodes, edges: nextEdges } = layoutFleetGraph(fleetNodes); + const { nodes: nextNodes, edges: nextEdges } = layoutFleetGraph(fleetNodes, { + mode: effectiveMode, + savedPositions: savedPositionsRef.current, + }); setFlowNodes(nextNodes); setFlowEdges(nextEdges); - // fleetNodes is intentionally excluded: we only relayout on shape changes. + // 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]); @@ -152,16 +329,24 @@ export function FleetTopology({ nodes: fleetNodes, onNodeClick }: FleetTopologyP setFlowNodes(current => current.map(flowNode => { const next = fleetNodes.find(n => String(n.id) === flowNode.id); if (!next) return flowNode; - const existing = (flowNode.data as FleetNodeData | undefined)?.node; + 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.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 } satisfies FleetNodeData }; + return { + ...flowNode, + data: { node: next, clusterLabel: existingData?.clusterLabel } satisfies FleetNodeData, + }; })); }, [fleetNodes, setFlowNodes]); @@ -172,6 +357,25 @@ export function FleetTopology({ nodes: fleetNodes, onNodeClick }: FleetTopologyP } }, []); + const handleNodeDragStop = useCallback((_event: React.MouseEvent, _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; @@ -188,8 +392,17 @@ export function FleetTopology({ nodes: fleetNodes, onNodeClick }: FleetTopologyP ); } + const allRemotesUnlabeled = effectiveMode === 'grouped' + && fleetNodes.filter(n => n.type !== 'local').every(n => (n.labels ?? []).length === 0); + return (
+ {isPaid && } + {isPaid && allRemotesUnlabeled && ( +
+ No node labels assigned. Add labels in Settings · Nodes to see remotes cluster. +
+ )}
{ + beforeEach(() => { + window.localStorage.clear(); + }); + + it('defaults to hub mode with no positions when storage is empty', () => { + const { result } = renderHook(() => useTopologyPreferences()); + expect(result.current.prefs.mode).toBe('hub'); + expect(result.current.prefs.positions).toEqual({}); + }); + + it('setMode persists to localStorage', () => { + const { result } = renderHook(() => useTopologyPreferences()); + act(() => result.current.setMode('grouped')); + expect(result.current.prefs.mode).toBe('grouped'); + const stored = JSON.parse(window.localStorage.getItem(PREFS_KEY)!); + expect(stored.mode).toBe('grouped'); + }); + + it('setPositions persists positions and survives a fresh render', () => { + const positions = { '7': { x: 12, y: 34 }, '8': { x: 56, y: 78 } }; + const first = renderHook(() => useTopologyPreferences()); + act(() => first.result.current.setPositions(positions)); + expect(first.result.current.prefs.positions).toEqual(positions); + + const second = renderHook(() => useTopologyPreferences()); + expect(second.result.current.prefs.positions).toEqual(positions); + }); + + it('falls back to defaults when stored JSON is corrupt', () => { + window.localStorage.setItem(PREFS_KEY, '{not-json'); + const { result } = renderHook(() => useTopologyPreferences()); + expect(result.current.prefs.mode).toBe('hub'); + expect(result.current.prefs.positions).toEqual({}); + }); + + it('rejects an invalid mode and uses the default instead', () => { + window.localStorage.setItem( + PREFS_KEY, + JSON.stringify({ mode: 'something-else', positions: {} }), + ); + const { result } = renderHook(() => useTopologyPreferences()); + expect(result.current.prefs.mode).toBe('hub'); + }); + + it('rejects malformed positions and uses an empty object instead', () => { + window.localStorage.setItem( + PREFS_KEY, + JSON.stringify({ mode: 'free', positions: { '1': 'oops' } }), + ); + const { result } = renderHook(() => useTopologyPreferences()); + expect(result.current.prefs.mode).toBe('free'); + expect(result.current.prefs.positions).toEqual({}); + }); + + it('updatePositions applies an updater function', () => { + const { result } = renderHook(() => useTopologyPreferences()); + act(() => result.current.setPositions({ '1': { x: 0, y: 0 } })); + act(() => result.current.updatePositions(prev => ({ ...prev, '2': { x: 1, y: 2 } }))); + expect(result.current.prefs.positions).toEqual({ + '1': { x: 0, y: 0 }, + '2': { x: 1, y: 2 }, + }); + }); +}); diff --git a/frontend/src/hooks/useTopologyPreferences.ts b/frontend/src/hooks/useTopologyPreferences.ts new file mode 100644 index 00000000..3d3e2cba --- /dev/null +++ b/frontend/src/hooks/useTopologyPreferences.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { LayoutMode, SavedPositions } from '@/lib/fleet-topology-layout'; + +const PREFS_KEY = 'sencho-topology-preferences'; + +export interface TopologyPreferences { + mode: LayoutMode; + positions: SavedPositions; +} + +const DEFAULT_PREFS: TopologyPreferences = { + mode: 'hub', + positions: {}, +}; + +function isLayoutMode(value: unknown): value is LayoutMode { + return value === 'hub' || value === 'grouped' || value === 'free'; +} + +function isSavedPositions(value: unknown): value is SavedPositions { + if (!value || typeof value !== 'object') return false; + for (const v of Object.values(value as Record)) { + if (!v || typeof v !== 'object') return false; + const p = v as { x?: unknown; y?: unknown }; + if (typeof p.x !== 'number' || typeof p.y !== 'number') return false; + } + return true; +} + +function loadPreferences(): TopologyPreferences { + try { + const stored = localStorage.getItem(PREFS_KEY); + if (!stored) return { ...DEFAULT_PREFS }; + const parsed = JSON.parse(stored) as Partial; + return { + mode: isLayoutMode(parsed.mode) ? parsed.mode : DEFAULT_PREFS.mode, + positions: isSavedPositions(parsed.positions) ? parsed.positions : {}, + }; + } catch { + return { ...DEFAULT_PREFS }; + } +} + +function savePreferences(prefs: TopologyPreferences) { + try { + localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); + } catch { + /* non-fatal: localStorage unavailable or quota exceeded */ + } +} + +export function useTopologyPreferences() { + const [prefs, setPrefs] = useState(loadPreferences); + + useEffect(() => { + savePreferences(prefs); + }, [prefs]); + + const setMode = useCallback((mode: LayoutMode) => { + setPrefs(prev => (prev.mode === mode ? prev : { ...prev, mode })); + }, []); + + const setPositions = useCallback((positions: SavedPositions) => { + setPrefs(prev => ({ ...prev, positions })); + }, []); + + const updatePositions = useCallback( + (updater: (current: SavedPositions) => SavedPositions) => { + setPrefs(prev => ({ ...prev, positions: updater(prev.positions) })); + }, + [], + ); + + return { prefs, setMode, setPositions, updatePositions }; +} diff --git a/frontend/src/lib/__tests__/fleet-topology-layout.test.ts b/frontend/src/lib/__tests__/fleet-topology-layout.test.ts new file mode 100644 index 00000000..9cdc9a9d --- /dev/null +++ b/frontend/src/lib/__tests__/fleet-topology-layout.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { + layoutFleetGraph, + LOCAL_CLUSTER_ID, + UNLABELED_CLUSTER_ID, + type FleetTopologyNode, +} from '../fleet-topology-layout'; + +function makeNode( + id: number, + type: 'local' | 'remote', + overrides: Partial = {}, +): FleetTopologyNode { + return { + id, + name: `node-${id}`, + type, + status: 'online', + cpuPercent: 10, + memPercent: 20, + diskPercent: 30, + stackCount: 1, + runningCount: 2, + critical: false, + labels: [], + cordoned: false, + cordonedReason: null, + latencyMs: null, + pilotLastSeen: null, + nodeMode: null, + ...overrides, + }; +} + +describe('layoutFleetGraph', () => { + it('returns empty arrays when there are no nodes', () => { + const result = layoutFleetGraph([], { mode: 'hub' }); + expect(result.nodes).toEqual([]); + expect(result.edges).toEqual([]); + }); + + it('hub mode emits one edge from local to each remote', () => { + const nodes = [ + makeNode(1, 'local'), + makeNode(2, 'remote'), + makeNode(3, 'remote'), + makeNode(4, 'remote'), + ]; + const result = layoutFleetGraph(nodes, { mode: 'hub' }); + expect(result.edges).toHaveLength(3); + for (const edge of result.edges) { + expect(edge.source).toBe('1'); + expect(['2', '3', '4']).toContain(edge.target); + } + expect(result.nodes.map(n => n.id).sort()).toEqual(['1', '2', '3', '4']); + for (const n of result.nodes) { + expect((n.data as { clusterLabel?: string }).clusterLabel).toBeUndefined(); + } + }); + + it('grouped mode assigns local to __local__ and remotes to their primary label cluster', () => { + const nodes = [ + makeNode(1, 'local', { labels: ['prod'] }), + makeNode(2, 'remote', { labels: ['prod', 'aws'] }), + makeNode(3, 'remote', { labels: ['staging'] }), + makeNode(4, 'remote', { labels: [] }), + ]; + const result = layoutFleetGraph(nodes, { mode: 'grouped' }); + const clusterByNodeId = new Map( + result.nodes.map(n => [n.id, (n.data as { clusterLabel?: string }).clusterLabel]), + ); + expect(clusterByNodeId.get('1')).toBe(LOCAL_CLUSTER_ID); + // Primary label is alphabetically first: 'aws' wins over 'prod'. + expect(clusterByNodeId.get('2')).toBe('aws'); + expect(clusterByNodeId.get('3')).toBe('staging'); + expect(clusterByNodeId.get('4')).toBe(UNLABELED_CLUSTER_ID); + // Real edges only — pseudo intra-cluster edges must not surface. + for (const edge of result.edges) { + expect(edge.source).toBe('1'); + } + }); + + it('grouped mode with no labels collapses remotes into the unlabeled cluster', () => { + const nodes = [ + makeNode(1, 'local'), + makeNode(2, 'remote'), + makeNode(3, 'remote'), + ]; + const result = layoutFleetGraph(nodes, { mode: 'grouped' }); + const cluster = (id: string) => + (result.nodes.find(n => n.id === id)?.data as { clusterLabel?: string }).clusterLabel; + expect(cluster('1')).toBe(LOCAL_CLUSTER_ID); + expect(cluster('2')).toBe(UNLABELED_CLUSTER_ID); + expect(cluster('3')).toBe(UNLABELED_CLUSTER_ID); + }); + + it('free mode overrides positions for ids present in savedPositions', () => { + const nodes = [ + makeNode(1, 'local'), + makeNode(2, 'remote'), + makeNode(3, 'remote'), + ]; + const saved = { + '2': { x: 500, y: 99 }, + }; + const result = layoutFleetGraph(nodes, { mode: 'free', savedPositions: saved }); + const byId = new Map(result.nodes.map(n => [n.id, n.position])); + expect(byId.get('2')).toEqual({ x: 500, y: 99 }); + // Node 3 has no saved position; falls back to Dagre-derived (some + // numeric coordinate, not the saved override). + const pos3 = byId.get('3')!; + expect(typeof pos3.x).toBe('number'); + expect(pos3).not.toEqual({ x: 500, y: 99 }); + }); + + it('free mode without any savedPositions matches hub layout positions', () => { + const nodes = [makeNode(1, 'local'), makeNode(2, 'remote')]; + const hub = layoutFleetGraph(nodes, { mode: 'hub' }); + const free = layoutFleetGraph(nodes, { mode: 'free' }); + const hubPositions = new Map(hub.nodes.map(n => [n.id, n.position])); + const freePositions = new Map(free.nodes.map(n => [n.id, n.position])); + for (const id of ['1', '2']) { + expect(freePositions.get(id)).toEqual(hubPositions.get(id)); + } + }); + + it('offline remote produces a dashed edge style', () => { + const nodes = [ + makeNode(1, 'local'), + makeNode(2, 'remote', { status: 'offline' }), + ]; + const result = layoutFleetGraph(nodes, { mode: 'hub' }); + const edge = result.edges[0]; + const style = edge.style as { strokeDasharray?: string }; + expect(style.strokeDasharray).toBe('4 4'); + }); +}); diff --git a/frontend/src/lib/fleet-topology-layout.ts b/frontend/src/lib/fleet-topology-layout.ts index cda2ee99..794d407e 100644 --- a/frontend/src/lib/fleet-topology-layout.ts +++ b/frontend/src/lib/fleet-topology-layout.ts @@ -12,14 +12,28 @@ export interface FleetTopologyNode { stackCount: number; runningCount: number; critical: boolean; + labels?: string[]; + cordoned?: boolean; + cordonedReason?: string | null; + latencyMs?: number | null; + pilotLastSeen?: number | null; + nodeMode?: string | null; } export interface FleetNodeData extends Record { node: FleetTopologyNode; + clusterLabel?: string; } +export type LayoutMode = 'hub' | 'grouped' | 'free'; + +export type SavedPositions = Record; + +export const LOCAL_CLUSTER_ID = '__local__'; +export const UNLABELED_CLUSTER_ID = '__unlabeled__'; + const NODE_WIDTH = 240; -const NODE_HEIGHT = 150; +const NODE_HEIGHT = 175; // ReactFlow inline styles cannot resolve CSS custom properties, so raw oklch // values are used. Values match the design tokens in frontend/src/index.css. @@ -41,11 +55,15 @@ function edgeStyle(remote: FleetTopologyNode): { return { stroke: EDGE_BRAND, strokeWidth: 1.5 }; } -export function layoutFleetGraph( - fleetNodes: FleetTopologyNode[], -): { nodes: Node[]; edges: Edge[] } { - if (fleetNodes.length === 0) return { nodes: [], edges: [] }; +// Multiple labels: alphabetically-first wins, giving a deterministic primary. +function clusterForRemote(remote: FleetTopologyNode): string { + const labels = remote.labels ?? []; + if (labels.length === 0) return UNLABELED_CLUSTER_ID; + const sorted = [...labels].sort((a, b) => a.localeCompare(b)); + return sorted[0]; +} +function buildHubLayout(fleetNodes: FleetTopologyNode[]): Map { const local = fleetNodes.find(n => n.type === 'local') ?? null; const remotes = fleetNodes.filter(n => n.type !== 'local'); @@ -56,7 +74,6 @@ export function layoutFleetGraph( for (const n of fleetNodes) { g.setNode(String(n.id), { width: NODE_WIDTH, height: NODE_HEIGHT }); } - if (local) { for (const r of remotes) { g.setEdge(String(local.id), String(r.id)); @@ -65,13 +82,119 @@ export function layoutFleetGraph( dagre.layout(g); - const flowNodes: Node[] = fleetNodes.map(n => { + const positions = new Map(); + for (const n of fleetNodes) { const pos = g.node(String(n.id)); + positions.set(String(n.id), { + x: pos.x - pos.width / 2, + y: pos.y - pos.height / 2, + }); + } + return positions; +} + +function buildGroupedLayout(fleetNodes: FleetTopologyNode[]): { + positions: Map; + clusterByNodeId: Map; +} { + const g = new dagre.graphlib.Graph({ compound: true }); + g.setGraph({ + rankdir: 'LR', + ranksep: 120, + nodesep: 28, + marginx: 24, + marginy: 24, + }); + g.setDefaultEdgeLabel(() => ({})); + + const clusterByNodeId = new Map(); + const clusterIds = new Set(); + + for (const n of fleetNodes) { + const cluster = n.type === 'local' ? LOCAL_CLUSTER_ID : clusterForRemote(n); + clusterByNodeId.set(String(n.id), cluster); + clusterIds.add(cluster); + } + + for (const cid of clusterIds) { + g.setNode(cid, {}); + } + for (const n of fleetNodes) { + g.setNode(String(n.id), { width: NODE_WIDTH, height: NODE_HEIGHT }); + g.setParent(String(n.id), clusterByNodeId.get(String(n.id))!); + } + + // Real hub edges (local→remote) plus intra-cluster pseudo-edges so dagre + // keeps cluster members vertically aligned. Intra-cluster edges are stripped + // from the rendered edge list later. + const local = fleetNodes.find(n => n.type === 'local') ?? null; + if (local) { + for (const r of fleetNodes) { + if (r.type === 'local') continue; + g.setEdge(String(local.id), String(r.id)); + } + } + dagre.layout(g); + + const positions = new Map(); + for (const n of fleetNodes) { + const pos = g.node(String(n.id)); + positions.set(String(n.id), { + x: pos.x - pos.width / 2, + y: pos.y - pos.height / 2, + }); + } + return { positions, clusterByNodeId }; +} + +export interface LayoutOptions { + mode: LayoutMode; + savedPositions?: SavedPositions; +} + +export function layoutFleetGraph( + fleetNodes: FleetTopologyNode[], + opts: LayoutOptions = { mode: 'hub' }, +): { nodes: Node[]; edges: Edge[] } { + if (fleetNodes.length === 0) return { nodes: [], edges: [] }; + + const local = fleetNodes.find(n => n.type === 'local') ?? null; + const remotes = fleetNodes.filter(n => n.type !== 'local'); + + let positions: Map; + let clusterByNodeId: Map | null = null; + + switch (opts.mode) { + case 'grouped': { + const result = buildGroupedLayout(fleetNodes); + positions = result.positions; + clusterByNodeId = result.clusterByNodeId; + break; + } + case 'free': { + positions = buildHubLayout(fleetNodes); + const saved = opts.savedPositions ?? {}; + for (const n of fleetNodes) { + const key = String(n.id); + if (saved[key]) positions.set(key, saved[key]); + } + break; + } + case 'hub': + default: + positions = buildHubLayout(fleetNodes); + break; + } + + const flowNodes: Node[] = fleetNodes.map(n => { + const key = String(n.id); + const pos = positions.get(key) ?? { x: 0, y: 0 }; + const cluster = clusterByNodeId?.get(key); return { - id: String(n.id), + id: key, type: 'fleetNode', - position: { x: pos.x - pos.width / 2, y: pos.y - pos.height / 2 }, - data: { node: n } satisfies FleetNodeData, + position: pos, + data: { node: n, clusterLabel: cluster } satisfies FleetNodeData, draggable: true, }; });