feat(mesh): add topology graph view and per-stack drill sheet (#1052)

Adds a Table/Graph toggle to Fleet → Traffic. Graph mode draws the fleet
as a ReactFlow diagram with a second toggle that switches edge encoding
between Tunnels (one edge per node pair, coloured by tunnel state) and
Aliases (same edges labelled with alias counts). Clicking a node card
opens the existing opt-in sheet, where each opted-in stack now has a
Topology button that opens a focused side sheet showing the stack at
the centre, the aliases it publishes, and the meshed consumer nodes
with per-tunnel state.

Reuses the existing /mesh/status and /mesh/aliases endpoints; no
backend changes. Inherits the Admiral gate from the parent Routing
tab. Layout helpers and the per-stack sheet are unit-tested.
This commit is contained in:
Anso
2026-05-15 08:07:34 -04:00
committed by GitHub
parent dd6f824fe4
commit 9e4b969dfb
8 changed files with 1222 additions and 26 deletions
@@ -0,0 +1,196 @@
import { describe, expect, it } from 'vitest';
import {
buildTunnelsGraph,
buildAliasesGraph,
buildStackTopologyGraph,
} from './mesh-topology-layout';
import type { MeshAlias, MeshNodeStatus } from '@/types/mesh';
function makeNode(over: Partial<MeshNodeStatus> & Pick<MeshNodeStatus, 'nodeId' | 'nodeName'>): MeshNodeStatus {
return {
nodeId: over.nodeId,
nodeName: over.nodeName,
enabled: over.enabled ?? false,
localForwarderListening: over.localForwarderListening ?? null,
pilotConnected: over.pilotConnected ?? false,
reachableMode: over.reachableMode ?? 'unreachable',
reachableReason: over.reachableReason ?? null,
optedInStacks: over.optedInStacks ?? [],
activeStreamCount: over.activeStreamCount ?? 0,
};
}
function makeAlias(over: Partial<MeshAlias> & Pick<MeshAlias, 'host' | 'nodeId'>): MeshAlias {
return {
host: over.host,
nodeId: over.nodeId,
nodeName: over.nodeName ?? `node-${over.nodeId}`,
stackName: over.stackName ?? 'stack-a',
serviceName: over.serviceName ?? 'svc',
port: over.port ?? 80,
};
}
describe('buildTunnelsGraph', () => {
it('returns empty result when no nodes', () => {
const result = buildTunnelsGraph([]);
expect(result.nodes).toHaveLength(0);
expect(result.edges).toHaveLength(0);
});
it('returns nodes but no edges when only the local node is present', () => {
const result = buildTunnelsGraph([
makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }),
]);
expect(result.nodes).toHaveLength(1);
expect(result.edges).toHaveLength(0);
});
it('returns one edge from local to every other node', () => {
const result = buildTunnelsGraph([
makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }),
makeNode({ nodeId: 2, nodeName: 'peer-a', reachableMode: 'pilot', enabled: true, pilotConnected: true }),
makeNode({ nodeId: 3, nodeName: 'peer-b', reachableMode: 'proxy', enabled: true }),
]);
expect(result.nodes).toHaveLength(3);
expect(result.edges).toHaveLength(2);
expect(result.edges.every((e) => e.source === '1')).toBe(true);
});
it('labels pilot edges with state suffixes', () => {
const result = buildTunnelsGraph([
makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }),
makeNode({ nodeId: 2, nodeName: 'on', reachableMode: 'pilot', enabled: true, pilotConnected: true }),
makeNode({ nodeId: 3, nodeName: 'off', reachableMode: 'pilot', enabled: true, pilotConnected: false }),
]);
const onEdge = result.edges.find((e) => e.target === '2');
const offEdge = result.edges.find((e) => e.target === '3');
expect(onEdge?.label).toBe('pilot · ok');
expect(offEdge?.label).toBe('pilot · idle');
});
it('dashes unreachable edges', () => {
const result = buildTunnelsGraph([
makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }),
makeNode({ nodeId: 2, nodeName: 'down', reachableMode: 'unreachable', enabled: false, reachableReason: 'auth failed' }),
]);
const edge = result.edges[0];
expect(edge).toBeDefined();
expect((edge?.style as { strokeDasharray?: string } | undefined)?.strokeDasharray).toBe('4 4');
expect(edge?.label).toBe('unreachable');
});
});
describe('buildAliasesGraph', () => {
const status: MeshNodeStatus[] = [
makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }),
makeNode({ nodeId: 2, nodeName: 'peer-a', reachableMode: 'pilot', enabled: true, pilotConnected: true }),
makeNode({ nodeId: 3, nodeName: 'peer-b', reachableMode: 'proxy', enabled: true }),
];
it('produces one edge per remote node like the tunnels graph', () => {
const aliases = [
makeAlias({ host: 'a.mesh', nodeId: 2 }),
makeAlias({ host: 'b.mesh', nodeId: 2 }),
makeAlias({ host: 'c.mesh', nodeId: 3 }),
];
const result = buildAliasesGraph(status, aliases);
expect(result.edges).toHaveLength(2);
const labels = result.edges.map((e) => e.label);
expect(labels).toContain('2 aliases');
expect(labels).toContain('1 alias');
});
it('labels edges with "no aliases" when remote owns none', () => {
const result = buildAliasesGraph(status, [makeAlias({ host: 'a.mesh', nodeId: 2 })]);
const noAliasEdge = result.edges.find((e) => e.target === '3');
expect(noAliasEdge?.label).toBe('no aliases');
});
it('encodes aliasCount on node data when in aliases mode', () => {
const aliases = [
makeAlias({ host: 'a.mesh', nodeId: 2 }),
makeAlias({ host: 'b.mesh', nodeId: 2 }),
];
const result = buildAliasesGraph(status, aliases);
const peerA = result.nodes.find((n) => n.id === '2');
const data = peerA?.data as { aliasCount: number } | undefined;
expect(data?.aliasCount).toBe(2);
});
});
describe('buildStackTopologyGraph', () => {
const status: MeshNodeStatus[] = [
makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }),
makeNode({ nodeId: 2, nodeName: 'peer-a', reachableMode: 'pilot', enabled: true, pilotConnected: true }),
makeNode({ nodeId: 3, nodeName: 'peer-b', reachableMode: 'proxy', enabled: true }),
];
it('returns empty when stack publishes nothing and no consumers', () => {
const result = buildStackTopologyGraph({
nodeId: 1,
stackName: 'empty',
status: [makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true })],
aliases: [],
});
expect(result.nodes).toHaveLength(0);
expect(result.edges).toHaveLength(0);
});
it('builds a center + alias + consumer fanout for a stack with 2 aliases and 2 consumers', () => {
const aliases = [
makeAlias({ host: 'a.mesh', nodeId: 1, stackName: 'api' }),
makeAlias({ host: 'b.mesh', nodeId: 1, stackName: 'api' }),
makeAlias({ host: 'other.mesh', nodeId: 1, stackName: 'other' }),
];
const result = buildStackTopologyGraph({
nodeId: 1,
stackName: 'api',
status,
aliases,
});
const centerNodes = result.nodes.filter((n) => n.type === 'stackCenter');
const aliasNodes = result.nodes.filter((n) => n.type === 'stackAlias');
const consumerNodes = result.nodes.filter((n) => n.type === 'stackConsumer');
expect(centerNodes).toHaveLength(1);
expect(aliasNodes).toHaveLength(2);
expect(consumerNodes).toHaveLength(2);
// 2 center→alias edges + 2 aliases × 2 consumers = 4 alias→consumer edges = 6 total.
expect(result.edges).toHaveLength(6);
});
it('filters aliases by both nodeId and stackName', () => {
const aliases = [
makeAlias({ host: 'a.mesh', nodeId: 1, stackName: 'api' }),
makeAlias({ host: 'wrong-stack.mesh', nodeId: 1, stackName: 'other' }),
makeAlias({ host: 'wrong-node.mesh', nodeId: 2, stackName: 'api' }),
];
const result = buildStackTopologyGraph({
nodeId: 1,
stackName: 'api',
status,
aliases,
});
const aliasNodes = result.nodes.filter((n) => n.type === 'stackAlias');
expect(aliasNodes).toHaveLength(1);
expect(aliasNodes[0]?.id).toBe('alias-1-api-a.mesh');
});
it('only treats meshed (enabled) remote nodes as consumers', () => {
const aliases = [makeAlias({ host: 'a.mesh', nodeId: 1, stackName: 'api' })];
const mixedStatus = [
makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }),
makeNode({ nodeId: 2, nodeName: 'meshed', reachableMode: 'pilot', enabled: true, pilotConnected: true }),
makeNode({ nodeId: 3, nodeName: 'unmeshed', reachableMode: 'pilot', enabled: false }),
];
const result = buildStackTopologyGraph({
nodeId: 1,
stackName: 'api',
status: mixedStatus,
aliases,
});
const consumerNodes = result.nodes.filter((n) => n.type === 'stackConsumer');
expect(consumerNodes).toHaveLength(1);
expect(consumerNodes[0]?.id).toBe('consumer-1-api-2');
});
});
+317
View File
@@ -0,0 +1,317 @@
import dagre from '@dagrejs/dagre';
import type { Edge, Node } from '@xyflow/react';
import type { MeshAlias, MeshNodeStatus } from '@/types/mesh';
export interface MeshNodeData extends Record<string, unknown> {
node: MeshNodeStatus;
aliasCount: number;
isOwnerView: boolean;
}
export interface MeshEdgeData extends Record<string, unknown> {
kind: 'tunnels' | 'aliases';
aliasCount: number;
remoteNodeId: number;
}
const NODE_WIDTH = 240;
const NODE_HEIGHT = 150;
const EDGE_BRAND = 'oklch(0.78 0.11 195)';
const EDGE_WARNING = 'oklch(0.75 0.14 75)';
const EDGE_MUTED = 'oklch(0.55 0 0)';
const EDGE_DESTRUCTIVE = 'oklch(0.65 0.2 28)';
type EdgeVisual = {
stroke: string;
strokeWidth: number;
strokeDasharray?: string;
};
function tunnelEdgeStyle(remote: MeshNodeStatus): EdgeVisual {
if (remote.reachableMode === 'unreachable') {
return { stroke: EDGE_DESTRUCTIVE, strokeWidth: 1, strokeDasharray: '4 4' };
}
if (remote.reachableMode === 'pilot' && !remote.pilotConnected) {
return { stroke: EDGE_MUTED, strokeWidth: 1, strokeDasharray: '4 4' };
}
if (!remote.enabled) {
return { stroke: EDGE_MUTED, strokeWidth: 1, strokeDasharray: '4 4' };
}
return { stroke: EDGE_BRAND, strokeWidth: 1.5 };
}
function tunnelEdgeLabel(remote: MeshNodeStatus): string {
if (remote.reachableMode === 'unreachable') return 'unreachable';
if (remote.reachableMode === 'pilot') {
return remote.pilotConnected ? 'pilot · ok' : 'pilot · idle';
}
if (remote.reachableMode === 'proxy') return 'proxy';
return '';
}
function aliasesEdgeStyle(remote: MeshNodeStatus, aliasCount: number): EdgeVisual {
if (aliasCount === 0) {
return { stroke: EDGE_MUTED, strokeWidth: 1, strokeDasharray: '4 4' };
}
if (remote.reachableMode === 'unreachable' || (remote.reachableMode === 'pilot' && !remote.pilotConnected)) {
return { stroke: EDGE_WARNING, strokeWidth: 1.5, strokeDasharray: '4 4' };
}
return { stroke: EDGE_BRAND, strokeWidth: 1.5 };
}
interface GraphLayoutResult {
nodes: Node[];
edges: Edge[];
}
function emptyResult(): GraphLayoutResult {
return { nodes: [], edges: [] };
}
function layoutNodesLR(meshNodes: MeshNodeStatus[]): Map<number, { x: number; y: number }> {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: 'LR', ranksep: 160, nodesep: 32, marginx: 20, marginy: 20 });
g.setDefaultEdgeLabel(() => ({}));
for (const n of meshNodes) {
g.setNode(String(n.nodeId), { width: NODE_WIDTH, height: NODE_HEIGHT });
}
const local = meshNodes.find((n) => n.reachableMode === 'local') ?? null;
if (local) {
for (const r of meshNodes) {
if (r.nodeId === local.nodeId) continue;
g.setEdge(String(local.nodeId), String(r.nodeId));
}
}
dagre.layout(g);
const positions = new Map<number, { x: number; y: number }>();
for (const n of meshNodes) {
const pos = g.node(String(n.nodeId));
positions.set(n.nodeId, { x: pos.x - pos.width / 2, y: pos.y - pos.height / 2 });
}
return positions;
}
function makeNode(
node: MeshNodeStatus,
aliasCount: number,
position: { x: number; y: number },
): Node {
return {
id: String(node.nodeId),
type: 'meshNode',
position,
data: { node, aliasCount, isOwnerView: false } satisfies MeshNodeData,
draggable: true,
};
}
export function buildTunnelsGraph(status: MeshNodeStatus[]): GraphLayoutResult {
if (status.length === 0) return emptyResult();
const positions = layoutNodesLR(status);
const local = status.find((n) => n.reachableMode === 'local') ?? null;
const flowNodes: Node[] = status.map((n) => {
const pos = positions.get(n.nodeId) ?? { x: 0, y: 0 };
return makeNode(n, 0, pos);
});
if (!local) return { nodes: flowNodes, edges: [] };
const flowEdges: Edge[] = status
.filter((n) => n.nodeId !== local.nodeId)
.map((remote) => {
const style = tunnelEdgeStyle(remote);
const label = tunnelEdgeLabel(remote);
return {
id: `tunnel-${local.nodeId}-${remote.nodeId}`,
source: String(local.nodeId),
target: String(remote.nodeId),
label,
style,
labelStyle: { fill: 'oklch(0.85 0 0)', fontSize: 10 },
labelBgStyle: { fill: 'oklch(0.18 0 0)', fillOpacity: 0.9 },
labelBgPadding: [4, 2] as [number, number],
data: {
kind: 'tunnels',
aliasCount: 0,
remoteNodeId: remote.nodeId,
} satisfies MeshEdgeData,
animated: false,
};
});
return { nodes: flowNodes, edges: flowEdges };
}
export function buildAliasesGraph(
status: MeshNodeStatus[],
aliases: MeshAlias[],
): GraphLayoutResult {
if (status.length === 0) return emptyResult();
const positions = layoutNodesLR(status);
const local = status.find((n) => n.reachableMode === 'local') ?? null;
const aliasCountByOwner = new Map<number, number>();
for (const a of aliases) {
aliasCountByOwner.set(a.nodeId, (aliasCountByOwner.get(a.nodeId) ?? 0) + 1);
}
const flowNodes: Node[] = status.map((n) => {
const pos = positions.get(n.nodeId) ?? { x: 0, y: 0 };
return makeNode(n, aliasCountByOwner.get(n.nodeId) ?? 0, pos);
});
if (!local) return { nodes: flowNodes, edges: [] };
const flowEdges: Edge[] = status
.filter((n) => n.nodeId !== local.nodeId)
.map((remote) => {
const remoteOwns = aliasCountByOwner.get(remote.nodeId) ?? 0;
const style = aliasesEdgeStyle(remote, remoteOwns);
const label = remoteOwns === 0
? 'no aliases'
: `${remoteOwns} ${remoteOwns === 1 ? 'alias' : 'aliases'}`;
return {
id: `aliases-${local.nodeId}-${remote.nodeId}`,
source: String(local.nodeId),
target: String(remote.nodeId),
label,
style,
labelStyle: { fill: 'oklch(0.85 0 0)', fontSize: 10 },
labelBgStyle: { fill: 'oklch(0.18 0 0)', fillOpacity: 0.9 },
labelBgPadding: [4, 2] as [number, number],
data: {
kind: 'aliases',
aliasCount: remoteOwns,
remoteNodeId: remote.nodeId,
} satisfies MeshEdgeData,
animated: false,
};
});
return { nodes: flowNodes, edges: flowEdges };
}
export interface StackTopologyInput {
nodeId: number;
stackName: string;
status: MeshNodeStatus[];
aliases: MeshAlias[];
}
export interface StackTopologyVertex {
stack: { id: string; name: string };
aliases: Array<{ id: string; host: string; port: number; service: string }>;
consumers: Array<{ id: string; node: MeshNodeStatus }>;
}
// Per-stack sheet uses a fixed three-column layout (stack centre, alias column,
// consumer column) instead of Dagre because the graph is always small and a
// deterministic layout reads better than auto-routed edges for ~10 vertices.
const STACK_CENTER_X = 0;
const STACK_CENTER_Y = 0;
const STACK_ALIAS_X = 220;
const STACK_CONSUMER_X = 460;
const STACK_VERTICAL_GAP = 80;
function stackVertexLayout(
aliasCount: number,
consumerCount: number,
): { center: { x: number; y: number }; aliasPositions: Array<{ x: number; y: number }>; consumerPositions: Array<{ x: number; y: number }> } {
const aliasStartY = -((aliasCount - 1) * STACK_VERTICAL_GAP) / 2;
const consumerStartY = -((consumerCount - 1) * STACK_VERTICAL_GAP) / 2;
return {
center: { x: STACK_CENTER_X, y: STACK_CENTER_Y },
aliasPositions: Array.from({ length: aliasCount }, (_, i) => ({
x: STACK_ALIAS_X,
y: aliasStartY + i * STACK_VERTICAL_GAP,
})),
consumerPositions: Array.from({ length: consumerCount }, (_, i) => ({
x: STACK_CONSUMER_X,
y: consumerStartY + i * STACK_VERTICAL_GAP,
})),
};
}
export function buildStackTopologyGraph(input: StackTopologyInput): GraphLayoutResult {
const { nodeId, stackName, status, aliases } = input;
const stackAliases = aliases.filter((a) => a.nodeId === nodeId && a.stackName === stackName);
const consumers = status.filter((s) => s.enabled && s.nodeId !== nodeId);
if (stackAliases.length === 0 && consumers.length === 0) {
return emptyResult();
}
const layout = stackVertexLayout(stackAliases.length, consumers.length);
const stackVertexId = `stack-${nodeId}-${stackName}`;
const flowNodes: Node[] = [];
flowNodes.push({
id: stackVertexId,
type: 'stackCenter',
position: layout.center,
data: { stackName, nodeId },
draggable: true,
});
const aliasVertexId = (host: string) => `alias-${nodeId}-${stackName}-${host}`;
const consumerVertexId = (id: number) => `consumer-${nodeId}-${stackName}-${id}`;
stackAliases.forEach((alias, i) => {
flowNodes.push({
id: aliasVertexId(alias.host),
type: 'stackAlias',
position: layout.aliasPositions[i] ?? { x: 0, y: 0 },
data: {
host: alias.host,
port: alias.port,
service: alias.serviceName,
},
draggable: true,
});
});
consumers.forEach((node, i) => {
flowNodes.push({
id: consumerVertexId(node.nodeId),
type: 'stackConsumer',
position: layout.consumerPositions[i] ?? { x: 0, y: 0 },
data: { node },
draggable: true,
});
});
const flowEdges: Edge[] = [];
for (const alias of stackAliases) {
flowEdges.push({
id: `e-stack-${aliasVertexId(alias.host)}`,
source: stackVertexId,
target: aliasVertexId(alias.host),
style: { stroke: EDGE_BRAND, strokeWidth: 1.5 },
animated: false,
});
for (const node of consumers) {
const style = aliasesEdgeStyle(node, 1);
flowEdges.push({
id: `e-${aliasVertexId(alias.host)}-to-${node.nodeId}`,
source: aliasVertexId(alias.host),
target: consumerVertexId(node.nodeId),
style,
animated: false,
});
}
}
return { nodes: flowNodes, edges: flowEdges };
}