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 = ''; }} /> + + +