+ {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,
};
});