mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
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).
This commit is contained in:
@@ -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> = {},
|
||||
): 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<string, string | undefined>(
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
node: FleetTopologyNode;
|
||||
clusterLabel?: string;
|
||||
}
|
||||
|
||||
export type LayoutMode = 'hub' | 'grouped' | 'free';
|
||||
|
||||
export type SavedPositions = Record<string, { x: number; y: number }>;
|
||||
|
||||
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<string, { x: number; y: number }> {
|
||||
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<string, { x: number; y: number }>();
|
||||
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<string, { x: number; y: number }>;
|
||||
clusterByNodeId: Map<string, string>;
|
||||
} {
|
||||
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<string, string>();
|
||||
const clusterIds = new Set<string>();
|
||||
|
||||
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<string, { x: number; y: number }>();
|
||||
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<string, { x: number; y: number }>;
|
||||
let clusterByNodeId: Map<string, string> | 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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user