From e6a67a6e5fb5bf1b7ad61d566ebe9ecb31746dad Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Mon, 10 Aug 2026 17:24:10 -0400 Subject: [PATCH] ui: improve topology graph readability (#136) Adds a matrix (adjacency-grid) view alongside the existing layered mode, and reworks layered-mode edge routing to stop cutting through unrelated nodes and labels: - Layered mode: multi-layer-spanning edges (a proxy hop straight into a service several dependency-layers deep, a deep service-to-service edge, or an egress hop back to a distant proxy) now reserve a dummy waypoint slot in every intermediate layer, so the crossing-reduction sweep pushes real nodes out of the way instead of letting the edge draw a raw curve through them. Default edge labels anchor to that same reserved slot rather than the raw (and potentially occupied) endpoint midpoint. - Mutual pairs (A->B and B->A between the same two nodes, e.g. a two-node cycle) get separated into two parallel tracks - their curves and labels used to be geometrically identical and fully overlap. - Node highlighting is click-only (selectedNodeId), not hover. - Matrix mode: adjacency grid ordered proxies-then-services, filled cell = edge, independent row/column hover highlighting (previously a single shared index meant only the diagonal could ever highlight). Also adds a /debug/topology page (unauthenticated, not in the sidebar) that renders a pasted or file-loaded GraphJson through the real topology components with no backend, for iterating on layout math against arbitrary topologies. Star, radial, and grouped layout modes were explored and implemented along the way but ultimately dropped in favor of layered + matrix. --- members/nullnet-server/ui/src/App.tsx | 4 + .../components/topology/LayoutModeToggle.tsx | 49 ++ .../components/topology/TopologyContext.tsx | 25 +- .../src/components/topology/TopologyGraph.tsx | 55 +- .../components/topology/TopologyGraphSvg.tsx | 553 ++++++++++-------- .../components/topology/TopologyMatrix.tsx | 169 ++++++ .../ui/src/components/topology/ZoomFrame.tsx | 8 +- .../ui/src/components/topology/layout.ts | 238 +++++++- .../ui/src/components/topology/types.ts | 6 + .../ui/src/pages/DebugTopology.tsx | 190 ++++++ 10 files changed, 1006 insertions(+), 291 deletions(-) create mode 100644 members/nullnet-server/ui/src/components/topology/LayoutModeToggle.tsx create mode 100644 members/nullnet-server/ui/src/components/topology/TopologyMatrix.tsx create mode 100644 members/nullnet-server/ui/src/pages/DebugTopology.tsx diff --git a/members/nullnet-server/ui/src/App.tsx b/members/nullnet-server/ui/src/App.tsx index 7431f4a..216bab2 100644 --- a/members/nullnet-server/ui/src/App.tsx +++ b/members/nullnet-server/ui/src/App.tsx @@ -12,6 +12,7 @@ import Events from './pages/Events'; import Certificates from './pages/Certificates'; import Topology from './pages/Topology'; import Users from './pages/Users'; +import DebugTopology from './pages/DebugTopology'; export default function App() { return ( @@ -20,6 +21,9 @@ export default function App() { } /> + {/* Not linked from the sidebar — dev tool, renders pasted/loaded + JSON with no backend involved, so it doesn't need auth. */} + } /> } /> } /> } /> diff --git a/members/nullnet-server/ui/src/components/topology/LayoutModeToggle.tsx b/members/nullnet-server/ui/src/components/topology/LayoutModeToggle.tsx new file mode 100644 index 0000000..a653b5e --- /dev/null +++ b/members/nullnet-server/ui/src/components/topology/LayoutModeToggle.tsx @@ -0,0 +1,49 @@ +import type { LayoutMode } from './types'; + +interface Props { + mode: LayoutMode; + onChange: (mode: LayoutMode) => void; +} + +const OPTIONS: { mode: LayoutMode; label: string }[] = [ + { mode: 'layered', label: 'layered' }, + { mode: 'matrix', label: 'matrix' }, +]; + +// Small segmented control for switching between the available topology +// layouts (layered default, matrix) — styled to match ZoomFrame's existing +// "reset view" overlay button. +export default function LayoutModeToggle({ mode, onChange }: Props) { + return ( +
+ {OPTIONS.map(opt => { + const active = opt.mode === mode; + return ( + + ); + })} +
+ ); +} diff --git a/members/nullnet-server/ui/src/components/topology/TopologyContext.tsx b/members/nullnet-server/ui/src/components/topology/TopologyContext.tsx index 44723c4..ac65d5e 100644 --- a/members/nullnet-server/ui/src/components/topology/TopologyContext.tsx +++ b/members/nullnet-server/ui/src/components/topology/TopologyContext.tsx @@ -1,7 +1,9 @@ import { createContext, useContext, useEffect, useMemo, useReducer, useRef } from 'react'; import type { ChainJson, GraphJson, ServiceJson, SessionJson } from '../../types'; -import type { PanelState } from './types'; -import { INTERNET_ID } from './types'; +import type { LayoutMode, PanelState } from './types'; +import { INTERNET_ID, LAYOUT_MODES } from './types'; + +const LAYOUT_MODE_STORAGE_KEY = 'topology-layout-mode'; import { useApi } from '../../hooks/useApi'; // ── Data context ────────────────────────────────────────────────────────────── @@ -25,6 +27,7 @@ const TopologyDataContext = createContext({ interface UIState { panel: PanelState; focusedClientIp: string | null; + layoutMode: LayoutMode; } export type UIAction = @@ -33,11 +36,19 @@ export type UIAction = | { type: 'PANEL_CLOSED' } | { type: 'CLIENT_FOCUSED'; ip: string } | { type: 'FOCUS_CLEARED' } - | { type: 'STACK_CHANGED' }; + | { type: 'STACK_CHANGED' } + | { type: 'LAYOUT_MODE_CHANGED'; mode: LayoutMode }; + +function loadInitialLayoutMode(): LayoutMode { + if (typeof localStorage === 'undefined') return 'layered'; + const stored = localStorage.getItem(LAYOUT_MODE_STORAGE_KEY); + return (LAYOUT_MODES as string[]).includes(stored ?? '') ? (stored as LayoutMode) : 'layered'; +} const initialUIState: UIState = { panel: null, focusedClientIp: null, + layoutMode: loadInitialLayoutMode(), }; function uiReducer(state: UIState, action: UIAction): UIState { @@ -78,6 +89,8 @@ function uiReducer(state: UIState, action: UIAction): UIState { return { ...state, focusedClientIp: null }; case 'STACK_CHANGED': return { ...state, panel: null, focusedClientIp: null }; + case 'LAYOUT_MODE_CHANGED': + return { ...state, layoutMode: action.mode }; } } @@ -122,6 +135,12 @@ export function TopologyProvider({ const [uiState, dispatch] = useReducer(uiReducer, initialUIState); + // Persist the chosen layout mode across reloads (kept out of the reducer to + // keep it a pure function of state+action). + useEffect(() => { + localStorage.setItem(LAYOUT_MODE_STORAGE_KEY, uiState.layoutMode); + }, [uiState.layoutMode]); + // Reset panel and focus when the active stack changes (not on initial mount). const prevStackRef = useRef(stack); useEffect(() => { diff --git a/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx b/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx index d9d2a46..f391ab2 100644 --- a/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx +++ b/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx @@ -1,6 +1,8 @@ import { useTopologyData, useTopologyUI } from './TopologyContext'; import TopologyGraphSvg from './TopologyGraphSvg'; +import TopologyMatrix from './TopologyMatrix'; import ZoomFrame from './ZoomFrame'; +import LayoutModeToggle from './LayoutModeToggle'; interface Props { height?: number | string; @@ -12,6 +14,7 @@ interface Props { export default function TopologyGraph({ height = 520, fill, anchor, grow }: Props) { const { graph } = useTopologyData(); const { + layoutMode, selectedNodeId, selectedEdgeKey, focusedNetIds, @@ -23,20 +26,44 @@ export default function TopologyGraph({ height = 520, fill, anchor, grow }: Prop if (!graph) return null; return ( - - dispatch({ type: 'NODE_CLICKED', nodeId: id })} - onEdgeClick={(fromId, toId, edgeIndices) => - dispatch({ type: 'EDGE_CLICKED', fromId, toId, edgeIndices }) - } - onBgClick={() => dispatch({ type: 'PANEL_CLOSED' })} - /> + dispatch({ type: 'LAYOUT_MODE_CHANGED', mode })} + /> + } + > + {layoutMode === 'matrix' ? ( + dispatch({ type: 'NODE_CLICKED', nodeId: id })} + onEdgeClick={(fromId, toId, edgeIndices) => + dispatch({ type: 'EDGE_CLICKED', fromId, toId, edgeIndices }) + } + onBgClick={() => dispatch({ type: 'PANEL_CLOSED' })} + /> + ) : ( + dispatch({ type: 'NODE_CLICKED', nodeId: id })} + onEdgeClick={(fromId, toId, edgeIndices) => + dispatch({ type: 'EDGE_CLICKED', fromId, toId, edgeIndices }) + } + onBgClick={() => dispatch({ type: 'PANEL_CLOSED' })} + /> + )} ); } diff --git a/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx b/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx index 6bd96e9..93b8a9b 100644 --- a/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx +++ b/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx @@ -1,7 +1,30 @@ import { useEffect, useRef } from 'react'; import type { GraphJson, SessionJson } from '../../types'; import { NODE_W, NODE_H, INET_W, INET_H, INTERNET_ID } from './types'; -import { buildTopoGraph, layoutNodes, svgDims, edgePath, egressEdgePath, inetEdgePath, edgeLabelPoints } from './layout'; +import type { TopoNode } from './types'; +import { + buildTopoGraph, layoutNodes, svgDims, + edgePath, edgeMidpoint, egressEdgePath, egressLabelPoint, inetEdgePath, longEdgePath, edgeLabelPoints, +} from './layout'; + +// A modest bow is enough to visually separate two curves, but their labels +// are much wider than the stroke — they need far more room to actually clear +// each other, so the two use different magnitudes of the same offset. +const MUTUAL_PATH_OFFSET = 10; +const MUTUAL_LABEL_OFFSET = 55; + +// Small dark pill behind a default edge label so two labels landing at +// nearby positions in a dense graph (e.g. an egress label and an unrelated +// edge's session count) stay legible instead of blending into each other. +function EdgeLabel({ x, y, text, color }: { x: number; y: number; text: string; color: string }) { + const w = text.length * 5.2 + 10; + return ( + + + {text} + + ); +} interface Props { graph: GraphJson; @@ -27,8 +50,9 @@ export default function TopologyGraphSvg({ onBgClick, }: Props) { const { nodes, edges } = buildTopoGraph(graph); + const nodeById = new Map(nodes.map(n => [n.id, n])); - const pos = layoutNodes(nodes, edges); + const { pos, waypoints } = layoutNodes(nodes, edges); const { w, h } = svgDims(pos, nodes); // Track edge keys already seen so a connect animation plays only once per @@ -60,8 +84,26 @@ export default function TopologyGraphSvg({ focusedNodeIds.add(INTERNET_ID); } + // Selecting a node highlights just its direct connections — independent + // of, and composable with, the client-session focus above (the two are + // effectively mutually exclusive in practice, so a plain OR of the two + // "should this be dimmed" conditions below is sufficient). + const highlightSource = selectedNodeId; + const highlightEdgeKeys = new Set(); + const highlightNodeIds = new Set(); + if (highlightSource) { + for (const e of edges) { + if (e.from === highlightSource || e.to === highlightSource) { + highlightEdgeKeys.add(`${e.from}\0${e.to}`); + highlightNodeIds.add(e.from); + highlightNodeIds.add(e.to); + } + } + highlightNodeIds.add(highlightSource); + } + function getNodeIp(id: string): string | null { - const node = nodes.find(n => n.id === id); + const node = nodeById.get(id); if (!node) return null; if (node.kind === 'proxy') return node.id; if (node.kind === 'service') return nodeIps.get(id) ?? null; @@ -70,6 +112,110 @@ export default function TopologyGraphSvg({ const interactive = !!(onNodeClick || onEdgeClick); + // Shared node visuals: internet pill / proxy dashed box / service card. + function renderNode(n: TopoNode, key: string) { + const p = pos.get(n.id); + if (!p) return null; + const isSel = n.id === selectedNodeId; + const nodeDimmed = (focusedNetIds != null && !focusedNodeIds.has(n.id)) || + (highlightSource != null && !highlightNodeIds.has(n.id)); + const clickHandler = onNodeClick ? (ev: { stopPropagation(): void }) => { ev.stopPropagation(); onNodeClick(n.id); } : undefined; + const clipId = `nc-${key}`; + + if (n.kind === 'internet') { + return ( + + {isSel && ( + + )} + + + ⬡ internet + + + ); + } + + if (n.kind === 'proxy') { + if (n.placeholder) { + return ( + + + + + + + + + + proxy + no active connections + + + ); + } + return ( + + + + + + + {isSel && ( + + )} + + + + proxy + {n.id} + + + ); + } + + const color = n.registered ? '#34d399' : '#f87171'; + const strokeColor = n.registered ? 'rgba(52,211,153,.3)' : 'rgba(248,113,113,.2)'; + const ip = nodeIps.get(n.id); + return ( + + {n.id} + + + + + + {isSel && ( + + )} + + + + {n.id} + {ip && ( + {ip} + )} + + {n.registered ? `${n.active_replica_count}/${n.replica_count} active` : 'unregistered'} + {n.registered && n.paused_replica_count > 0 ? ` · ${n.paused_replica_count} paused` : ''} + {n.entry_point ? ' · entry' : ''} + + + + ); + } + return ( } {/* Internet → Proxy edges */} - {/* eslint-disable-next-line react-hooks/refs -- see isNewEdge comment above */} - {edges.filter(e => e.isInternetEdge).map(e => { - const fp = pos.get(e.from); - const tp = pos.get(e.to); - if (!fp || !tp) return null; - const dimmed = focusedNetIds != null && !focusedNodeIds.has(e.to); - const edgeKey = `${e.from}\0${e.to}`; - const isNew = isNewEdge(edgeKey); - return ( - - ); - })} - - {/* Service / proxy edges */} - {/* eslint-disable-next-line react-hooks/refs -- see isNewEdge comment above */} - {edges.filter(e => !e.isInternetEdge).map(e => { - const fp = pos.get(e.from); - const tp = pos.get(e.to); - if (!fp || !tp) return null; - const edgeKey = `${e.from}\0${e.to}`; - const isSel = selectedEdgeKey === edgeKey; - const isNew = isNewEdge(edgeKey); - const dimmed = focusedNetIds != null && !focusedEdgeKeys.has(edgeKey); - const count = e.originalIndices.length; - const stroke = isSel - ? 'rgba(91,156,246,.9)' - : e.isEgress ? 'rgba(167,139,250,.55)' - : e.isProxyHop ? 'rgba(251,191,36,.35)' : 'rgba(255,255,255,.18)'; - const arrowId = isSel ? 'arr-sel' : e.isEgress ? 'arr-egress' : e.isProxyHop ? 'arr-proxy' : 'arr'; - const dash = isSel ? undefined : e.isEgress ? '2 4' : e.isProxyHop ? '4 3' : undefined; - const path = e.isEgress ? egressEdgePath(fp, tp) : edgePath(fp, tp); - const midX = (fp.x + tp.x) / 2 + NODE_W / 2; - const midY = (fp.y + tp.y) / 2 + NODE_H / 2; + {/* eslint-disable-next-line react-hooks/refs -- see isNewEdge comment above */} + {edges.filter(e => e.isInternetEdge).map(e => { + const fp = pos.get(e.from); + const tp = pos.get(e.to); + if (!fp || !tp) return null; + const dimmed = (focusedNetIds != null && !focusedNodeIds.has(e.to)) || + (highlightSource != null && !highlightNodeIds.has(e.to)); + const edgeKey = `${e.from}\0${e.to}`; + const isNew = isNewEdge(edgeKey); + return ( + + ); + })} - const isFocusedEdge = focusedNetIds != null && focusedEdgeKeys.has(edgeKey); - const session: SessionJson | null = isFocusedEdge - ? (focusedSessions?.find(s => e.originalIndices.some(idx => graph.edges[idx]?.net_id === s.network_id)) ?? null) - : null; - // For chain hops the session is null (chain sessions don't carry the client IP), - // but we can still display the VNI by pulling it directly from the graph edge. - const focusedNetId: number | null = isFocusedEdge - ? (session?.network_id ?? - e.originalIndices.map(idx => graph.edges[idx]?.net_id).find(id => id !== undefined && focusedNetIds!.has(id)) ?? - null) - : null; - const lp = isFocusedEdge ? edgeLabelPoints(fp, tp) : null; - const srcIp = isFocusedEdge ? getNodeIp(e.from) : null; - const dstIp = isFocusedEdge ? getNodeIp(e.to) : null; + {/* Service / proxy edges */} + {/* eslint-disable-next-line react-hooks/refs -- see isNewEdge comment above */} + {edges.filter(e => !e.isInternetEdge).map(e => { + const fp = pos.get(e.from); + const tp = pos.get(e.to); + if (!fp || !tp) return null; + const edgeKey = `${e.from}\0${e.to}`; + const isSel = selectedEdgeKey === edgeKey; + const isNew = isNewEdge(edgeKey); + const dimmed = (focusedNetIds != null && !focusedEdgeKeys.has(edgeKey)) || + (highlightSource != null && !highlightEdgeKeys.has(edgeKey)); + const count = e.originalIndices.length; + const stroke = isSel + ? 'rgba(91,156,246,.9)' + : e.isEgress ? 'rgba(167,139,250,.55)' + : e.isProxyHop ? 'rgba(251,191,36,.35)' : 'rgba(255,255,255,.18)'; + const arrowId = isSel ? 'arr-sel' : e.isEgress ? 'arr-egress' : e.isProxyHop ? 'arr-proxy' : 'arr'; + const dash = isSel ? undefined : e.isEgress ? '2 4' : e.isProxyHop ? '4 3' : undefined; + const wp = waypoints.get(edgeKey); + // A mutual pair (both A→B and B→A present, e.g. a two-node cycle) + // has a midpoint-symmetric geometry — without an offset, both + // directions draw the exact same curve and label position. + const isMutual = !e.isEgress && currentEdgeKeys.has(`${e.to}\0${e.from}`); + const mutualSign = isMutual ? (e.from < e.to ? -1 : 1) : 0; + // A layer-skipping egress edge gets the same reserved waypoint + // lane as any other long edge, so it no longer has to bow across + // whatever sits between the service and the (usually distant) + // proxy row. Adjacent-layer egress edges (no waypoints) keep the + // original right-bow routing, which is short enough to be fine. + const path = wp?.length ? longEdgePath(fp, wp, tp) + : e.isEgress ? egressEdgePath(fp, tp) + : edgePath(fp, tp, mutualSign * MUTUAL_PATH_OFFSET); + // edgeMidpoint assumes a direct fp-to-tp curve — on a waypoint- + // routed edge the actual path bends through a reserved slot that + // can be nowhere near that raw midpoint (and, being just the + // endpoints' midpoint, has no idea a real node might already sit + // there). Anchor the label to the middle waypoint instead, which + // is guaranteed collision-free by construction. + const { x: midX, y: midY } = wp?.length + ? { x: wp[Math.floor(wp.length / 2)].x + NODE_W / 2, y: wp[Math.floor(wp.length / 2)].y + NODE_H / 2 } + : edgeMidpoint(fp, tp, mutualSign * MUTUAL_LABEL_OFFSET); - return ( - { ev.stopPropagation(); onEdgeClick(e.from, e.to, e.originalIndices); } : undefined} - style={{ cursor: onEdgeClick ? 'pointer' : 'default', opacity: dimmed ? 0.1 : 1 }} - > - {onEdgeClick && } - + const isFocusedEdge = focusedNetIds != null && focusedEdgeKeys.has(edgeKey); + const session: SessionJson | null = isFocusedEdge + ? (focusedSessions?.find(s => e.originalIndices.some(idx => graph.edges[idx]?.net_id === s.network_id)) ?? null) + : null; + // For chain hops the session is null (chain sessions don't carry the client IP), + // but we can still display the VNI by pulling it directly from the graph edge. + const focusedNetId: number | null = isFocusedEdge + ? (session?.network_id ?? + e.originalIndices.map(idx => graph.edges[idx]?.net_id).find(id => id !== undefined && focusedNetIds!.has(id)) ?? + null) + : null; + const lp = isFocusedEdge ? edgeLabelPoints(fp, tp) : null; + const srcIp = isFocusedEdge ? getNodeIp(e.from) : null; + const dstIp = isFocusedEdge ? getNodeIp(e.to) : null; - {/* Egress edge marker label (bows out to the right of both nodes) */} - {interactive && e.isEgress && !isSel && ( - - egress - - )} + return ( + { ev.stopPropagation(); onEdgeClick(e.from, e.to, e.originalIndices); } : undefined} + style={{ cursor: onEdgeClick ? 'pointer' : 'default', opacity: dimmed ? 0.1 : 1 }} + > + {onEdgeClick && } + - {/* Default labels — hidden when client is focused or on egress edges */} - {interactive && !isSel && !isFocusedEdge && !e.isEgress && count > 1 && ( - - {count} sessions - - )} - {interactive && !isSel && !isFocusedEdge && !e.isEgress && count === 1 && e.setup_ms > 0 && ( - - net {e.net_id} · {e.setup_ms}ms - - )} + {/* Egress edge marker label — anchored to the curve's own bow + peak, or its waypoint-lane midpoint when it has one */} + {interactive && e.isEgress && !isSel && (() => { + const lp = wp?.length ? edgeMidpoint(fp, tp) : egressLabelPoint(fp, tp); + return ; + })()} - {/* Focused-client edge labels (session edges only, not egress) */} - {isFocusedEdge && !e.isEgress && lp && ( - - {srcIp && ( - - - - src {srcIp} - - + {/* Default labels — hidden when client is focused or on egress edges */} + {interactive && !isSel && !isFocusedEdge && !e.isEgress && count > 1 && ( + )} - {dstIp && ( - - - - dst {dstIp} - - + {interactive && !isSel && !isFocusedEdge && !e.isEgress && count === 1 && e.setup_ms > 0 && ( + )} - {focusedNetId !== null && ( - - - - VNI {focusedNetId} - - {session && ( - <> - - src {session.client_net} + + {/* Focused-client edge labels (session edges only, not egress) */} + {isFocusedEdge && !e.isEgress && lp && ( + + {srcIp && ( + + + + src {srcIp} - - dst {session.server_net} + + )} + {dstIp && ( + + + + dst {dstIp} - + + )} + {focusedNetId !== null && ( + + + + VNI {focusedNetId} + + {session && ( + <> + + src {session.client_net} + + + dst {session.server_net} + + + )} + )} )} - )} - - ); - })} - - {/* Nodes */} - {nodes.map((n, ni) => { - const p = pos.get(n.id); - if (!p) return null; - const isSel = n.id === selectedNodeId; - const nodeDimmed = focusedNetIds != null && !focusedNodeIds.has(n.id); - const clickHandler = onNodeClick ? (ev: { stopPropagation(): void }) => { ev.stopPropagation(); onNodeClick(n.id); } : undefined; - const clipId = `nc-${ni}`; - - if (n.kind === 'internet') { - return ( - - {isSel && ( - - )} - - - ⬡ internet - - - ); - } - - if (n.kind === 'proxy') { - if (n.placeholder) { - return ( - - - - - - - - - - proxy - no active connections - - ); - } - return ( - - - - - - - {isSel && ( - - )} - - - - proxy - {n.id} - - - ); - } + })} - const color = n.registered ? '#34d399' : '#f87171'; - const strokeColor = n.registered ? 'rgba(52,211,153,.3)' : 'rgba(248,113,113,.2)'; - const ip = nodeIps.get(n.id); - return ( - - {n.id} - - - - - - {isSel && ( - - )} - - - - {n.id} - {ip && ( - {ip} - )} - - {n.registered ? `${n.active_replica_count}/${n.replica_count} active` : 'unregistered'} - {n.registered && n.paused_replica_count > 0 ? ` · ${n.paused_replica_count} paused` : ''} - {n.entry_point ? ' · entry' : ''} - - - - ); - })} + {/* Nodes */} + {nodes.map((n, ni) => renderNode(n, String(ni)))} ); } diff --git a/members/nullnet-server/ui/src/components/topology/TopologyMatrix.tsx b/members/nullnet-server/ui/src/components/topology/TopologyMatrix.tsx new file mode 100644 index 0000000..bce867f --- /dev/null +++ b/members/nullnet-server/ui/src/components/topology/TopologyMatrix.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react'; +import type { GraphJson } from '../../types'; +import type { TopoNode, TopoEdge } from './types'; +import { buildTopoGraph } from './layout'; + +interface Props { + graph: GraphJson; + selectedNodeId?: string | null; + selectedEdgeKey?: string | null; + onNodeClick?: (id: string) => void; + onEdgeClick?: (fromId: string, toId: string, edgeIndices: number[]) => void; + onBgClick?: () => void; +} + +const CELL = 26; +const LABEL_COL_W = 150; +const HEADER_ROW_H = 150; + +function shortLabel(id: string): string { + return id.length > 18 ? `${id.slice(0, 17)}…` : id; +} + +function kindColor(n: TopoNode): string { + if (n.kind === 'proxy') return 'rgba(251,191,36,.85)'; + return n.kind === 'service' && n.registered ? 'rgba(52,211,153,.85)' : 'rgba(248,113,113,.7)'; +} + +function edgeColor(e: TopoEdge): string { + return e.isEgress ? 'rgba(167,139,250,.75)' : e.isProxyHop ? 'rgba(251,191,36,.7)' : 'rgba(91,156,246,.75)'; +} + +// Adjacency-matrix view of the same graph the other layouts draw as a +// node-link diagram: rows/cols ordered proxies-then-services (internet +// omitted — every proxy trivially connects to it, an uninformative row/col), +// filled cell = row → col has an edge. Zero edge crossings by construction, +// at the cost of not reading as a path the way the node-link views do. +export default function TopologyMatrix({ + graph, selectedNodeId = null, selectedEdgeKey = null, onNodeClick, onEdgeClick, onBgClick, +}: Props) { + const { nodes, edges } = buildTopoGraph(graph); + const order = nodes + .filter(n => n.kind !== 'internet') + .sort((a, b) => (a.kind === b.kind ? a.id.localeCompare(b.id) : a.kind === 'proxy' ? -1 : 1)); + const indexOf = new Map(order.map((n, i) => [n.id, i])); + + const edgeByPair = new Map(); + for (const e of edges) { + if (e.isInternetEdge) continue; + edgeByPair.set(`${e.from}\0${e.to}`, e); + } + + // The row/column crosshair tracks two INDEPENDENT indices — hovering a cell + // highlights its own row and column (which are different, off-diagonal + // indices), while hovering a row/column label or selecting a node + // highlights that single node's row and column (necessarily the same + // index, so the crosshair sits on the diagonal in that case only). + const [hoveredCell, setHoveredCell] = useState<{ rowId: string; colId: string } | null>(null); + let hi = -1, hj = -1; + if (hoveredCell) { + hi = indexOf.get(hoveredCell.rowId) ?? -1; + hj = indexOf.get(hoveredCell.colId) ?? -1; + } else if (selectedNodeId) { + hi = hj = indexOf.get(selectedNodeId) ?? -1; + } else if (selectedEdgeKey) { + const e = edgeByPair.get(selectedEdgeKey); + if (e) { hi = indexOf.get(e.from) ?? -1; hj = indexOf.get(e.to) ?? -1; } + } + const hasHighlight = hi >= 0 || hj >= 0; + + const w = LABEL_COL_W + order.length * CELL; + const h = HEADER_ROW_H + order.length * CELL; + + return ( + + {onBgClick && } + + {order.map((_, i) => ( + + ))} + {order.map((_, j) => ( + + ))} + + {hi >= 0 && ( + + )} + {hj >= 0 && ( + + )} + + {/* Every (row, col) position is hoverable — not just the ones with an + edge — so pointing anywhere in the grid tells you which row/column + it belongs to. Diagonal cells stay content-free (no self-edges). */} + {order.map((rowNode, i) => order.map((colNode, j) => { + if (i === j) return null; + const key = `${rowNode.id}\0${colNode.id}`; + const e = edgeByPair.get(key); + const isSel = selectedEdgeKey === key; + const dimmed = hasHighlight && i !== hi && j !== hj; + const x = LABEL_COL_W + j * CELL, y = HEADER_ROW_H + i * CELL; + const count = e?.originalIndices.length ?? 0; + return ( + setHoveredCell({ rowId: rowNode.id, colId: colNode.id })} + onMouseLeave={() => setHoveredCell(null)} + onClick={e && onEdgeClick ? (ev: React.MouseEvent) => { ev.stopPropagation(); onEdgeClick(e.from, e.to, e.originalIndices); } : undefined} + style={{ cursor: e && onEdgeClick ? 'pointer' : 'default', opacity: dimmed ? 0.15 : 1 }}> + + {e && ( + <> + {`${e.from} → ${e.to}${count > 1 ? ` (${count} sessions)` : ''}`} + + {count > 1 && ( + + {count} + + )} + + )} + + ); + }))} + + {order.map((n, i) => ( + { ev.stopPropagation(); onNodeClick(n.id); } : undefined} + onMouseEnter={() => setHoveredCell({ rowId: n.id, colId: n.id })} onMouseLeave={() => setHoveredCell(null)} + style={{ cursor: onNodeClick ? 'pointer' : 'default' }}> + + + + {shortLabel(n.id)} + + + ))} + + {order.map((n, j) => { + const x = LABEL_COL_W + j * CELL + CELL / 2; + const y = HEADER_ROW_H - 8; + return ( + { ev.stopPropagation(); onNodeClick(n.id); } : undefined} + onMouseEnter={() => setHoveredCell({ rowId: n.id, colId: n.id })} onMouseLeave={() => setHoveredCell(null)} + style={{ cursor: onNodeClick ? 'pointer' : 'default' }}> + + + + + {shortLabel(n.id)} + + + + ); + })} + + ); +} diff --git a/members/nullnet-server/ui/src/components/topology/ZoomFrame.tsx b/members/nullnet-server/ui/src/components/topology/ZoomFrame.tsx index ce54736..693eac3 100644 --- a/members/nullnet-server/ui/src/components/topology/ZoomFrame.tsx +++ b/members/nullnet-server/ui/src/components/topology/ZoomFrame.tsx @@ -14,6 +14,7 @@ interface Props { fill?: boolean; anchor?: 'center' | 'top-left'; grow?: boolean; + overlay?: React.ReactNode; children: React.ReactNode; } @@ -31,7 +32,7 @@ function computeHome(cw: number, ch: number, contentH: number, anchor: 'center' return { scale, tx, ty }; } -export default function ZoomFrame({ height, fill, anchor = 'center', grow, children }: Props) { +export default function ZoomFrame({ height, fill, anchor = 'center', grow, overlay, children }: Props) { const [zoom, setZoom] = useState({ scale: 1, tx: 0, ty: 0 }); const dragging = useRef<{ startX: number; startY: number; startTx: number; startTy: number } | null>(null); const containerRef = useRef(null); @@ -145,6 +146,11 @@ export default function ZoomFrame({ height, fill, anchor = 'center', grow, child {children} + {overlay && ( +
+ {overlay} +
+ )} {!isDefault && ( + + + { const f = e.target.files?.[0]; if (f) loadFile(f); e.target.value = ''; }} /> + + +