mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
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:
@@ -94,6 +94,19 @@ This is the first place to look when a connection isn't behaving as expected.
|
||||
|
||||
The masthead has a **Mesh activity** button that opens the fleet-wide event log. Every route resolution, tunnel state change, opt-in, opt-out, and probe is recorded there. Filter by alias, source, type, or message. Useful for understanding what just happened when something flips state.
|
||||
|
||||
## Topology view
|
||||
|
||||
The Routing tab has a **Table** / **Graph** toggle in its header. Graph mode draws the fleet as a node-and-edge diagram so it is clear at a glance which nodes are meshed, which tunnels are live, and where aliases are published.
|
||||
|
||||
A second toggle picks what the edges encode:
|
||||
|
||||
- **Tunnels** shows one edge per remote node, coloured by tunnel state. A solid brand edge labelled `pilot · ok` or `proxy` means traffic is ready to flow. A dashed muted edge labelled `pilot · idle` means the Pilot agent is offline. A dashed red edge labelled `unreachable` means the credentials or remote build cannot carry mesh traffic; the node card shows the specific reason.
|
||||
- **Aliases** keeps the same node layout and labels each edge with the number of aliases the remote node publishes. A remote that publishes nothing reads `no aliases`.
|
||||
|
||||
Click any node card to open the opt-in sheet for that node. On each opted-in stack row the sheet shows a **Topology** button that opens a focused diagram for that one stack: the stack at the centre, every alias it publishes branching out, and a column of meshed consumer nodes with their tunnel state. Use it to confirm what a stack exposes and which peers can reach it.
|
||||
|
||||
The graph reads the same `/mesh/status` and `/mesh/aliases` data the Table view does, so any opt-in or opt-out refreshes both views.
|
||||
|
||||
## V1 limitations
|
||||
|
||||
A few things are deliberately out of scope for the first release:
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SystemSheet } from '@/components/ui/system-sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import type { MeshStackEntry } from '@/types/mesh';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, Workflow } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -13,9 +13,10 @@ interface Props {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
onChanged: () => void;
|
||||
onViewTopology?: (stack: string) => void;
|
||||
}
|
||||
|
||||
export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged }: Props) {
|
||||
export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged, onViewTopology }: Props) {
|
||||
const [stacks, setStacks] = useState<MeshStackEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -120,13 +121,25 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
|
||||
{pendingStack === stack.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-stat-subtitle" />
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={stack.optedIn ? 'outline' : 'default'}
|
||||
onClick={() => setConfirmStack(stack)}
|
||||
>
|
||||
{stack.optedIn ? 'Remove from mesh' : 'Add to mesh'}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{stack.optedIn && onViewTopology && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onViewTopology(stack.name)}
|
||||
aria-label={`View topology for ${stack.name}`}
|
||||
>
|
||||
<Workflow className="w-3 h-3 mr-1" /> Topology
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={stack.optedIn ? 'outline' : 'default'}
|
||||
onClick={() => setConfirmStack(stack)}
|
||||
>
|
||||
{stack.optedIn ? 'Remove from mesh' : 'Add to mesh'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MeshStackTopologySheet } from './MeshStackTopologySheet';
|
||||
import type { MeshAlias, MeshNodeStatus } from '@/types/mesh';
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
ReactFlow: () => null,
|
||||
Background: () => null,
|
||||
Handle: () => null,
|
||||
Position: { Left: 'left', Right: 'right' },
|
||||
useNodesState: <T,>() => [[] as T[], () => {}, () => {}],
|
||||
useEdgesState: <T,>() => [[] as T[], () => {}, () => {}],
|
||||
}));
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const baseStatus: MeshNodeStatus[] = [
|
||||
makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }),
|
||||
makeNode({ nodeId: 2, nodeName: 'peer-a', reachableMode: 'pilot', enabled: true, pilotConnected: true }),
|
||||
];
|
||||
|
||||
describe('MeshStackTopologySheet', () => {
|
||||
it('renders the empty-state message when the stack publishes no aliases', () => {
|
||||
render(
|
||||
<MeshStackTopologySheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
nodeId={1}
|
||||
nodeName="local"
|
||||
stackName="api"
|
||||
status={baseStatus}
|
||||
aliases={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/No published mesh services/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/0 aliases/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the alias and consumer counts in the meta band', () => {
|
||||
const aliases = [
|
||||
makeAlias({ host: 'a.mesh', nodeId: 1, stackName: 'api' }),
|
||||
makeAlias({ host: 'b.mesh', nodeId: 1, stackName: 'api' }),
|
||||
];
|
||||
render(
|
||||
<MeshStackTopologySheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
nodeId={1}
|
||||
nodeName="local"
|
||||
stackName="api"
|
||||
status={baseStatus}
|
||||
aliases={aliases}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/2 aliases · 1 consumer/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No published mesh services/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('counts only meshed remote nodes as consumers', () => {
|
||||
const aliases = [makeAlias({ host: 'a.mesh', nodeId: 1, stackName: 'api' })];
|
||||
const status: MeshNodeStatus[] = [
|
||||
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 }),
|
||||
];
|
||||
render(
|
||||
<MeshStackTopologySheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
nodeId={1}
|
||||
nodeName="local"
|
||||
stackName="api"
|
||||
status={status}
|
||||
aliases={aliases}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/1 alias · 1 consumer/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Handle,
|
||||
Position,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Boxes, Globe, Server, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SystemSheet } from '@/components/ui/system-sheet';
|
||||
import { buildStackTopologyGraph } from '@/lib/mesh-topology-layout';
|
||||
import type { MeshAlias, MeshNodeStatus } from '@/types/mesh';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
nodeId: number | null;
|
||||
nodeName: string | null;
|
||||
stackName: string | null;
|
||||
status: MeshNodeStatus[];
|
||||
aliases: MeshAlias[];
|
||||
}
|
||||
|
||||
interface StackCenterData extends Record<string, unknown> {
|
||||
stackName: string;
|
||||
nodeId: number;
|
||||
}
|
||||
interface StackAliasData extends Record<string, unknown> {
|
||||
host: string;
|
||||
port: number;
|
||||
service: string;
|
||||
}
|
||||
interface StackConsumerData extends Record<string, unknown> {
|
||||
node: MeshNodeStatus;
|
||||
}
|
||||
|
||||
function consumerDot(node: MeshNodeStatus): string {
|
||||
if (node.reachableMode === 'unreachable') return 'bg-destructive';
|
||||
if (node.reachableMode === 'pilot' && !node.pilotConnected) return 'bg-warning';
|
||||
return 'bg-success';
|
||||
}
|
||||
|
||||
function consumerStateLabel(node: MeshNodeStatus): string {
|
||||
if (node.reachableMode === 'unreachable') return 'unreachable';
|
||||
if (node.reachableMode === 'pilot' && !node.pilotConnected) return 'pilot · idle';
|
||||
if (node.reachableMode === 'pilot') return 'pilot · ok';
|
||||
if (node.reachableMode === 'proxy') return 'proxy';
|
||||
return node.reachableMode;
|
||||
}
|
||||
|
||||
function StackCenterCard({ data }: { data: StackCenterData }) {
|
||||
return (
|
||||
<div className="w-[180px] rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel ring-1 ring-brand/40">
|
||||
<Handle type="source" position={Position.Right} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-card-border">
|
||||
<Boxes className="h-3.5 w-3.5 text-brand shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-brand">stack</span>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-xs font-medium text-stat-value truncate">{data.stackName}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StackAliasCard({ data }: { data: StackAliasData }) {
|
||||
return (
|
||||
<div className="w-[200px] rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
|
||||
<Handle type="target" position={Position.Left} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
||||
<Handle type="source" position={Position.Right} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-card-border">
|
||||
<Globe className="h-3.5 w-3.5 text-stat-icon shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">alias</span>
|
||||
</div>
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
<div className="text-xs font-mono text-stat-value truncate">{data.host}</div>
|
||||
<div className="font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
{data.service} · :{data.port}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StackConsumerCard({ data }: { data: StackConsumerData }) {
|
||||
const node = data.node;
|
||||
const dimmed = node.reachableMode === 'unreachable';
|
||||
return (
|
||||
<div className={cn(
|
||||
'w-[200px] rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel',
|
||||
dimmed && 'opacity-70',
|
||||
)}>
|
||||
<Handle type="target" position={Position.Left} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-card-border">
|
||||
<span aria-hidden="true" className={cn('h-2 w-2 rounded-full shrink-0', consumerDot(node))} />
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">consumer</span>
|
||||
{dimmed && <AlertTriangle className="h-3 w-3 text-destructive ml-auto" strokeWidth={1.75} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-card-border">
|
||||
<Server className="h-3.5 w-3.5 text-stat-icon shrink-0" strokeWidth={1.5} />
|
||||
<span className="text-xs font-medium text-stat-value truncate">{node.nodeName}</span>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
{consumerStateLabel(node)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
stackCenter: StackCenterCard,
|
||||
stackAlias: StackAliasCard,
|
||||
stackConsumer: StackConsumerCard,
|
||||
};
|
||||
|
||||
export function MeshStackTopologySheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
nodeId,
|
||||
nodeName,
|
||||
stackName,
|
||||
status,
|
||||
aliases,
|
||||
}: Props) {
|
||||
const [flowNodes, setFlowNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
// Shape-key: only relayout when the structural identity of the graph changes
|
||||
// (different stack selected, an alias appears/disappears, a consumer node goes
|
||||
// in/out of mesh, or a consumer's tunnel state flips). Scalar field changes on
|
||||
// the same nodes fall through and do not reset user-dragged positions.
|
||||
const shapeKey = useMemo(() => {
|
||||
if (nodeId === null || stackName === null) return 'empty';
|
||||
const aliasKey = aliases
|
||||
.filter((a) => a.nodeId === nodeId && a.stackName === stackName)
|
||||
.map((a) => a.host)
|
||||
.sort()
|
||||
.join(',');
|
||||
const consumerKey = status
|
||||
.filter((s) => s.enabled && s.nodeId !== nodeId)
|
||||
.map((s) => `${s.nodeId}:${s.reachableMode}:${s.pilotConnected ? 1 : 0}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
return `${nodeId}/${stackName}/${aliasKey}/${consumerKey}`;
|
||||
}, [nodeId, stackName, status, aliases]);
|
||||
|
||||
useEffect(() => {
|
||||
if (nodeId === null || stackName === null) {
|
||||
setFlowNodes([]);
|
||||
setFlowEdges([]);
|
||||
return;
|
||||
}
|
||||
const next = buildStackTopologyGraph({ nodeId, stackName, status, aliases });
|
||||
setFlowNodes(next.nodes);
|
||||
setFlowEdges(next.edges);
|
||||
// Intentionally exclude status/aliases raw refs: relayout only on shapeKey changes,
|
||||
// so polls that don't change reachability don't snap user-dragged nodes back.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shapeKey, setFlowNodes, setFlowEdges]);
|
||||
|
||||
const stackAliasCount = useMemo(() => {
|
||||
if (nodeId === null || stackName === null) return 0;
|
||||
return aliases.filter((a) => a.nodeId === nodeId && a.stackName === stackName).length;
|
||||
}, [nodeId, stackName, aliases]);
|
||||
|
||||
const consumerCount = useMemo(() => {
|
||||
if (nodeId === null) return 0;
|
||||
return status.filter((s) => s.enabled && s.nodeId !== nodeId).length;
|
||||
}, [nodeId, status]);
|
||||
|
||||
const meta = `${stackAliasCount} ${stackAliasCount === 1 ? 'alias' : 'aliases'} · ${consumerCount} ${consumerCount === 1 ? 'consumer' : 'consumers'}`;
|
||||
const crumbName = stackName ?? '';
|
||||
const ownerName = nodeName ?? '';
|
||||
|
||||
return (
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Mesh', ownerName, 'topology']}
|
||||
name={crumbName}
|
||||
meta={meta}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-stat-subtitle leading-snug">
|
||||
Aliases this stack publishes and the meshed nodes that can reach them. Edge styling
|
||||
reflects each consumer's tunnel state.
|
||||
</p>
|
||||
|
||||
{stackAliasCount === 0 ? (
|
||||
<div className="rounded border border-dashed border-card-border bg-card/50 p-8 text-center">
|
||||
<Boxes className="w-10 h-10 text-stat-subtitle mx-auto mb-3" strokeWidth={1.5} />
|
||||
<div className="text-sm font-display italic mb-1">No published mesh services</div>
|
||||
<div className="text-xs text-stat-subtitle">
|
||||
This stack is in the mesh but exposes no service ports for other meshed stacks to reach.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<div className="h-[420px] w-full">
|
||||
<ReactFlow
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, minZoom: 0.4 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodesConnectable={false}
|
||||
className="bg-background"
|
||||
>
|
||||
<Background gap={20} size={1} className="opacity-30" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Handle,
|
||||
Position,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Server, Radio, RadioTower, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
buildTunnelsGraph,
|
||||
buildAliasesGraph,
|
||||
type MeshNodeData,
|
||||
} from '@/lib/mesh-topology-layout';
|
||||
import type { MeshAlias, MeshNodeStatus, MeshReachableMode } from '@/types/mesh';
|
||||
|
||||
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 MINIMAP_DESTRUCTIVE = 'oklch(0.65 0.2 28)';
|
||||
|
||||
export type MeshGraphEdgeMode = 'tunnels' | 'aliases';
|
||||
|
||||
interface MeshTopologyGraphProps {
|
||||
status: MeshNodeStatus[];
|
||||
aliases: MeshAlias[];
|
||||
edgeMode: MeshGraphEdgeMode;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
function statusDot(node: MeshNodeStatus): string {
|
||||
if (node.reachableMode === 'unreachable') return 'bg-destructive';
|
||||
if (node.reachableMode === 'pilot' && !node.pilotConnected) return 'bg-warning';
|
||||
if (!node.enabled) return 'bg-muted-foreground';
|
||||
return 'bg-success';
|
||||
}
|
||||
|
||||
function reachableModeLabel(mode: MeshReachableMode): string {
|
||||
switch (mode) {
|
||||
case 'local': return 'local';
|
||||
case 'pilot': return 'pilot';
|
||||
case 'proxy': return 'proxy';
|
||||
case 'unreachable': return 'unreachable';
|
||||
}
|
||||
}
|
||||
|
||||
function ModeIcon({ mode, connected }: { mode: MeshReachableMode; connected: boolean }) {
|
||||
if (mode === 'unreachable') return <AlertTriangle className="h-3 w-3 text-destructive" strokeWidth={1.75} />;
|
||||
if (mode === 'pilot' && !connected) return <Radio className="h-3 w-3 text-warning" strokeWidth={1.75} />;
|
||||
if (mode === 'pilot') return <RadioTower className="h-3 w-3 text-brand" strokeWidth={1.75} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
function MeshNodeCard({ data, selected }: { data: MeshNodeData; selected?: boolean }) {
|
||||
const node = data.node;
|
||||
const isLocal = node.reachableMode === 'local';
|
||||
const dimmed = node.reachableMode === 'unreachable';
|
||||
const optedInCount = node.optedInStacks.length;
|
||||
const stackLabel = optedInCount === 1 ? 'stack' : 'stacks';
|
||||
const streamLabel = node.activeStreamCount === 1 ? 'stream' : 'streams';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-[240px] rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors',
|
||||
'hover:border-t-card-border-hover cursor-pointer',
|
||||
isLocal && 'ring-1 ring-brand/40',
|
||||
dimmed && 'opacity-70',
|
||||
selected && 'ring-1 ring-brand',
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-card-border">
|
||||
<span aria-hidden="true" className={cn('h-2 w-2 rounded-full shrink-0', statusDot(node))} />
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{node.enabled ? (dimmed ? 'unreachable' : 'meshed') : 'unmeshed'}
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<ModeIcon mode={node.reachableMode} connected={node.pilotConnected} />
|
||||
<span className={cn(
|
||||
'font-mono text-[9px] uppercase tracking-[0.22em]',
|
||||
isLocal ? 'text-brand' : 'text-muted-foreground',
|
||||
)}>
|
||||
{isLocal ? 'Local' : 'Remote'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-card-border">
|
||||
<Server className="h-3.5 w-3.5 text-stat-icon shrink-0" strokeWidth={1.5} />
|
||||
<span className="text-xs font-medium text-stat-value truncate">{node.nodeName}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-1.5 border-b border-card-border space-y-0.5">
|
||||
<div className="flex items-center justify-between font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
<span className="uppercase tracking-[0.18em] text-[9px]">mode</span>
|
||||
<span className="text-stat-value">{reachableModeLabel(node.reachableMode)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
<span className="uppercase tracking-[0.18em] text-[9px]">opted in</span>
|
||||
<span className="text-stat-value">{optedInCount} {stackLabel}</span>
|
||||
</div>
|
||||
{data.aliasCount > 0 && (
|
||||
<div className="flex items-center justify-between font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
<span className="uppercase tracking-[0.18em] text-[9px]">publishes</span>
|
||||
<span className="text-stat-value">{data.aliasCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-1.5 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
{node.activeStreamCount} {streamLabel}
|
||||
{node.reachableMode === 'unreachable' && node.reachableReason ? (
|
||||
<span className="ml-2 text-destructive truncate">· {node.reachableReason}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
meshNode: MeshNodeCard,
|
||||
};
|
||||
|
||||
function nodeStateEqual(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
|
||||
&& a.optedInStacks.length === b.optedInStacks.length;
|
||||
}
|
||||
|
||||
export function MeshTopologyGraph({ status, aliases, edgeMode, onNodeClick }: MeshTopologyGraphProps) {
|
||||
// Mirror FleetTopology's onNodeClick ref pattern: handlers may change on every
|
||||
// parent render, but ReactFlow's onNodeClick is stable, so route through a ref
|
||||
// to avoid recreating the callback (and thus rebuilding the flow) on every render.
|
||||
const onNodeClickRef = useRef(onNodeClick);
|
||||
onNodeClickRef.current = onNodeClick;
|
||||
|
||||
// Shape key: relayout only when nodes are added/removed or reachability flips.
|
||||
const shapeKey = useMemo(
|
||||
() => status
|
||||
.map((n) => `${n.nodeId}:${n.reachableMode}:${n.enabled ? 1 : 0}:${n.pilotConnected ? 1 : 0}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[status],
|
||||
);
|
||||
|
||||
const [flowNodes, setFlowNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const result = edgeMode === 'tunnels'
|
||||
? buildTunnelsGraph(status)
|
||||
: buildAliasesGraph(status, aliases);
|
||||
setFlowNodes(result.nodes);
|
||||
setFlowEdges(result.edges);
|
||||
// Intentionally exclude `status` and `aliases` raw refs so we only relayout when
|
||||
// the shape (or selected edgeMode) changes; per-poll metric tweaks fall through
|
||||
// the live-update effect below without resetting user-dragged positions.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shapeKey, edgeMode, setFlowNodes, setFlowEdges]);
|
||||
|
||||
// Refresh node card data on prop change without touching positions. The /mesh/status
|
||||
// poll yields fresh JSON objects every cycle, so a reference equality check would
|
||||
// replace data on every poll and defeat the purpose. Compare by value instead.
|
||||
useEffect(() => {
|
||||
setFlowNodes((current) => current.map((flowNode) => {
|
||||
const next = status.find((n) => String(n.nodeId) === flowNode.id);
|
||||
if (!next) return flowNode;
|
||||
const existing = (flowNode.data as MeshNodeData | undefined);
|
||||
const aliasCount = edgeMode === 'aliases'
|
||||
? aliases.filter((a) => a.nodeId === next.nodeId).length
|
||||
: 0;
|
||||
if (
|
||||
existing
|
||||
&& existing.aliasCount === aliasCount
|
||||
&& nodeStateEqual(existing.node, next)
|
||||
) {
|
||||
return flowNode;
|
||||
}
|
||||
return { ...flowNode, data: { node: next, aliasCount, isOwnerView: false } satisfies MeshNodeData };
|
||||
}));
|
||||
}, [status, aliases, edgeMode, setFlowNodes]);
|
||||
|
||||
const handleNodeClick = useCallback((_event: React.MouseEvent, flowNode: Node) => {
|
||||
const id = Number(flowNode.id);
|
||||
if (!Number.isNaN(id)) {
|
||||
onNodeClickRef.current?.(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const miniMapNodeColor = useCallback((n: Node) => {
|
||||
const data = n.data as MeshNodeData | undefined;
|
||||
const topo = data?.node;
|
||||
if (!topo) return MINIMAP_MUTED;
|
||||
if (topo.reachableMode === 'unreachable') return MINIMAP_DESTRUCTIVE;
|
||||
if (topo.reachableMode === 'pilot' && !topo.pilotConnected) return MINIMAP_WARNING;
|
||||
if (!topo.enabled) return MINIMAP_MUTED;
|
||||
return MINIMAP_BRAND;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<div className="h-[560px] w-full">
|
||||
<ReactFlow
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, minZoom: 0.4 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodesConnectable={false}
|
||||
className="bg-background"
|
||||
>
|
||||
<Background gap={20} size={1} className="opacity-30" />
|
||||
<Controls
|
||||
className="!bg-card !border-card-border !shadow-card-bevel [&>button]:!bg-card [&>button]:!border-card-border [&>button]:!text-foreground [&>button:hover]:!bg-muted"
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap
|
||||
className="!bg-card !border-card-border !shadow-card-bevel"
|
||||
nodeColor={miniMapNodeColor}
|
||||
maskColor="oklch(0 0 0 / 0.2)"
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,25 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeftRight, Loader2, ScrollText } from 'lucide-react';
|
||||
import { ArrowLeftRight, Loader2, ScrollText, Table2, Network } from 'lucide-react';
|
||||
import { RoutingNodeCard } from './RoutingNodeCard';
|
||||
import { MeshOptInSheet } from './MeshOptInSheet';
|
||||
import { MeshRouteDetailSheet } from './MeshRouteDetailSheet';
|
||||
import { MeshDiagnosticsSheet } from './MeshDiagnosticsSheet';
|
||||
import { MeshActivitySheet } from './MeshActivitySheet';
|
||||
import { MeshTopologyGraph, type MeshGraphEdgeMode } from './MeshTopologyGraph';
|
||||
import { MeshStackTopologySheet } from './MeshStackTopologySheet';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import type { MeshAlias, MeshNodeStatus, MeshProbeResult } from '@/types/mesh';
|
||||
|
||||
type RoutingViewMode = 'table' | 'graph';
|
||||
|
||||
interface TopologyStackTarget {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stack: string;
|
||||
}
|
||||
|
||||
export function RoutingTab() {
|
||||
const [status, setStatus] = useState<MeshNodeStatus[]>([]);
|
||||
const [aliases, setAliases] = useState<MeshAlias[]>([]);
|
||||
@@ -18,6 +29,9 @@ export function RoutingTab() {
|
||||
const [diagnosticsNode, setDiagnosticsNode] = useState<{ id: number; name: string } | null>(null);
|
||||
const [routeDetailAlias, setRouteDetailAlias] = useState<string | null>(null);
|
||||
const [activityOpen, setActivityOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<RoutingViewMode>('table');
|
||||
const [edgeMode, setEdgeMode] = useState<MeshGraphEdgeMode>('tunnels');
|
||||
const [topologyStack, setTopologyStack] = useState<TopologyStackTarget | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -58,6 +72,12 @@ export function RoutingTab() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleGraphNodeClick = useCallback((nodeId: number) => {
|
||||
const target = status.find((s) => s.nodeId === nodeId);
|
||||
if (!target) return;
|
||||
setOptInNode({ id: target.nodeId, name: target.nodeName });
|
||||
}, [status]);
|
||||
|
||||
const totalAliases = aliases.length;
|
||||
const meshedNodes = status.filter((s) => s.enabled).length;
|
||||
// A node is "reachable for mesh" when it is local, a pilot with an
|
||||
@@ -121,6 +141,10 @@ export function RoutingTab() {
|
||||
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
|
||||
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
|
||||
activityOpen={activityOpen} setActivityOpen={setActivityOpen}
|
||||
topologyStack={topologyStack}
|
||||
setTopologyStack={setTopologyStack}
|
||||
status={status}
|
||||
aliases={aliases}
|
||||
onChanged={() => { void refresh(); }}
|
||||
/>
|
||||
</div>
|
||||
@@ -130,25 +154,60 @@ export function RoutingTab() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<RoutingMasthead meshedNodes={meshedNodes} reachableNodes={reachableNodes} totalAliases={totalAliases} onShowActivity={() => setActivityOpen(true)} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{status.map((s) => (
|
||||
<RoutingNodeCard
|
||||
key={s.nodeId}
|
||||
status={s}
|
||||
aliases={aliases}
|
||||
onAddStack={() => setOptInNode({ id: s.nodeId, name: s.nodeName })}
|
||||
onShowDiagnostics={() => setDiagnosticsNode({ id: s.nodeId, name: s.nodeName })}
|
||||
onShowAlias={(alias) => setRouteDetailAlias(alias)}
|
||||
onTestUpstream={testUpstream}
|
||||
onChanged={() => { void refresh(); }}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SegmentedControl<RoutingViewMode>
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
ariaLabel="Routing view mode"
|
||||
options={[
|
||||
{ value: 'table', label: 'Table', icon: Table2 },
|
||||
{ value: 'graph', label: 'Graph', icon: Network },
|
||||
]}
|
||||
/>
|
||||
{viewMode === 'graph' && (
|
||||
<SegmentedControl<MeshGraphEdgeMode>
|
||||
value={edgeMode}
|
||||
onChange={setEdgeMode}
|
||||
ariaLabel="Mesh graph edge mode"
|
||||
options={[
|
||||
{ value: 'tunnels', label: 'Tunnels' },
|
||||
{ value: 'aliases', label: 'Aliases', badge: totalAliases },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
{viewMode === 'table' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{status.map((s) => (
|
||||
<RoutingNodeCard
|
||||
key={s.nodeId}
|
||||
status={s}
|
||||
aliases={aliases}
|
||||
onAddStack={() => setOptInNode({ id: s.nodeId, name: s.nodeName })}
|
||||
onShowDiagnostics={() => setDiagnosticsNode({ id: s.nodeId, name: s.nodeName })}
|
||||
onShowAlias={(alias) => setRouteDetailAlias(alias)}
|
||||
onTestUpstream={testUpstream}
|
||||
onChanged={() => { void refresh(); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<MeshTopologyGraph
|
||||
status={status}
|
||||
aliases={aliases}
|
||||
edgeMode={edgeMode}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
/>
|
||||
)}
|
||||
<SheetsRoot
|
||||
optInNode={optInNode} setOptInNode={setOptInNode}
|
||||
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
|
||||
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
|
||||
activityOpen={activityOpen} setActivityOpen={setActivityOpen}
|
||||
topologyStack={topologyStack}
|
||||
setTopologyStack={setTopologyStack}
|
||||
status={status}
|
||||
aliases={aliases}
|
||||
onChanged={() => { void refresh(); }}
|
||||
/>
|
||||
</div>
|
||||
@@ -190,17 +249,26 @@ function SheetsRoot(props: {
|
||||
setRouteDetailAlias: (v: string | null) => void;
|
||||
activityOpen: boolean;
|
||||
setActivityOpen: (v: boolean) => void;
|
||||
topologyStack: TopologyStackTarget | null;
|
||||
setTopologyStack: (v: TopologyStackTarget | null) => void;
|
||||
status: MeshNodeStatus[];
|
||||
aliases: MeshAlias[];
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const optInNode = props.optInNode;
|
||||
return (
|
||||
<>
|
||||
{props.optInNode && (
|
||||
{optInNode && (
|
||||
<MeshOptInSheet
|
||||
open={!!props.optInNode}
|
||||
open={!!optInNode}
|
||||
onOpenChange={(open) => { if (!open) props.setOptInNode(null); }}
|
||||
nodeId={props.optInNode.id}
|
||||
nodeName={props.optInNode.name}
|
||||
nodeId={optInNode.id}
|
||||
nodeName={optInNode.name}
|
||||
onChanged={props.onChanged}
|
||||
onViewTopology={(stack) => {
|
||||
props.setTopologyStack({ nodeId: optInNode.id, nodeName: optInNode.name, stack });
|
||||
props.setOptInNode(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MeshDiagnosticsSheet
|
||||
@@ -218,6 +286,15 @@ function SheetsRoot(props: {
|
||||
open={props.activityOpen}
|
||||
onOpenChange={props.setActivityOpen}
|
||||
/>
|
||||
<MeshStackTopologySheet
|
||||
open={!!props.topologyStack}
|
||||
onOpenChange={(open) => { if (!open) props.setTopologyStack(null); }}
|
||||
nodeId={props.topologyStack?.nodeId ?? null}
|
||||
nodeName={props.topologyStack?.nodeName ?? null}
|
||||
stackName={props.topologyStack?.stack ?? null}
|
||||
status={props.status}
|
||||
aliases={props.aliases}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user