mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +00:00
554f662563
Add `currentlyResolvable: boolean` per entry in `MeshNodeStatus.optedInStacks`, derived from the existing alias cache so the new field stays consistent with `/api/mesh/aliases` without any extra Dockerode or cross-node inspect on the status path. The Routing tab renders an amber `suspended` pill for entries whose stack is opted in but currently has no running services, plus a single explanatory caption below the suspended list. Resolves the contradictory `Mesh stacks: 1 / Aliases: 0 / No mesh services on this node yet` copy on the node card when a meshed stack's container has been stopped; the misleading line is now only shown when the node truly has no opt-ins. The opt-in itself remains sticky: when the stack starts again, its aliases reappear automatically on the next refresh. Defensive de-dup in the UI filters suspended entries against the live alias snapshot to handle the transient gap where `/mesh/status` and `/mesh/aliases` return slightly inconsistent views from their separate fetches. Tests: - new `mesh-status-resolvability.test.ts` (6 cases) locks the resolvable / suspended / mixed / empty / per-node-scoping / stale-alias-no-phantom invariants for `getStatus`. - `mesh-topology-layout.test.ts` gains a `stacksKey` resolvability-flip case and a `meshNodeStateEqual` case asserting a resolvability flip on an otherwise identical stack registers as a state change so the topology layout re-runs. Operator docs gain one new troubleshooting accordion in `/docs/features/sencho-mesh.mdx` explaining the suspended state.
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
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;
|
|
}
|
|
|
|
// MiniMap colour literals. ReactFlow cannot resolve CSS vars inside inline
|
|
// SVG fills, so the minimap takes raw oklch strings.
|
|
export const MINIMAP_BRAND = 'oklch(0.78 0.11 195)';
|
|
export const MINIMAP_WARNING = 'oklch(0.75 0.14 75)';
|
|
export const MINIMAP_MUTED = 'oklch(0.55 0 0)';
|
|
export const MINIMAP_DESTRUCTIVE = 'oklch(0.65 0.2 28)';
|
|
|
|
export function miniMapColorFor(node: MeshNodeStatus | undefined): string {
|
|
if (!node) return MINIMAP_MUTED;
|
|
if (node.reachableMode === 'unreachable') return MINIMAP_DESTRUCTIVE;
|
|
if (node.reachableMode === 'pilot' && !node.pilotConnected) return MINIMAP_WARNING;
|
|
if (!node.enabled) return MINIMAP_MUTED;
|
|
return MINIMAP_BRAND;
|
|
}
|
|
|
|
export function stacksKey(stacks: MeshNodeStatus['optedInStacks']): string {
|
|
return [...stacks]
|
|
.map((s) => `${s.stackName}:${s.currentlyResolvable ? '1' : '0'}`)
|
|
.sort()
|
|
.join(' ');
|
|
}
|
|
|
|
export function meshNodeStateEqual(a: MeshNodeStatus, b: MeshNodeStatus): boolean {
|
|
return a.nodeId === b.nodeId
|
|
&& a.enabled === b.enabled
|
|
&& a.reachableMode === b.reachableMode
|
|
&& a.pilotConnected === b.pilotConnected
|
|
&& a.reachableReason === b.reachableReason
|
|
&& a.activeStreamCount === b.activeStreamCount
|
|
&& stacksKey(a.optedInStacks) === stacksKey(b.optedInStacks);
|
|
}
|
|
|
|
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 } 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 };
|
|
}
|