diff --git a/members/nullnet-server/ui/src/components/Layout.tsx b/members/nullnet-server/ui/src/components/Layout.tsx index 6c3459f..91369a9 100644 --- a/members/nullnet-server/ui/src/components/Layout.tsx +++ b/members/nullnet-server/ui/src/components/Layout.tsx @@ -4,7 +4,7 @@ import { useApi } from '../hooks/useApi'; import type { SessionJson } from '../types'; import { useRef, useState, useEffect } from 'react'; -type Page = 'dashboard' | 'services' | 'nodes' | 'sessions' | 'pool' | 'config' | 'certificates' | 'events'; +type Page = 'dashboard' | 'topology' | 'services' | 'nodes' | 'sessions' | 'pool' | 'config' | 'certificates' | 'events'; interface Props { page: Page; @@ -17,6 +17,7 @@ const NAV = [ group: 'Overview', items: [ { id: 'dashboard', icon: '⊞', label: 'Dashboard', to: '/' }, + { id: 'topology', icon: '◎', label: 'Topology', to: '/topology' }, ], }, { diff --git a/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx b/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx index 9bec3f0..d9d2a46 100644 --- a/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx +++ b/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx @@ -2,7 +2,14 @@ import { useTopologyData, useTopologyUI } from './TopologyContext'; import TopologyGraphSvg from './TopologyGraphSvg'; import ZoomFrame from './ZoomFrame'; -export default function TopologyGraph() { +interface Props { + height?: number | string; + fill?: boolean; + anchor?: 'center' | 'top-left'; + grow?: boolean; +} + +export default function TopologyGraph({ height = 520, fill, anchor, grow }: Props) { const { graph } = useTopologyData(); const { selectedNodeId, @@ -16,7 +23,7 @@ export default function TopologyGraph() { if (!graph) return null; return ( - + ch) { scale = (ch / contentH) * 0.9; - tx = (cw * (1 - scale)) / 2; - ty = (ch - contentH * scale) / 2; - } else { + if (anchor === 'center') { + tx = (cw * (1 - scale)) / 2; + ty = (ch - contentH * scale) / 2; + } + } else if (anchor === 'center') { ty = (ch - contentH) / 2; } return { scale, tx, ty }; } -export default function ZoomFrame({ height, children }: Props) { +export default function ZoomFrame({ height, fill, anchor = 'center', grow, 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); @@ -34,9 +39,9 @@ export default function ZoomFrame({ height, children }: Props) { const homeZoom = useRef({ scale: 1, tx: 0, ty: 0 }); const prevContentH = useRef(0); - // Initial centering before first paint — sets prevContentH so the ResizeObserver - // below skips its first fire (same size) and only re-fits on actual graph changes. + // Initial fit — skipped in grow mode (container sizes to content, no transform needed). useLayoutEffect(() => { + if (grow) return; const container = containerRef.current; const content = contentRef.current; if (!container || !content) return; @@ -45,13 +50,14 @@ export default function ZoomFrame({ height, children }: Props) { const contentH = content.clientHeight; if (!contentH) return; prevContentH.current = contentH; - const home = computeHome(cw, ch, contentH); + const home = computeHome(cw, ch, contentH, anchor); homeZoom.current = home; setZoom(home); }, []); // eslint-disable-line react-hooks/exhaustive-deps - // Re-fit when the SVG grows or shrinks (new nodes added/removed, window resize). + // Re-fit when the SVG grows or shrinks — skipped in grow mode. useEffect(() => { + if (grow) return; const container = containerRef.current; const content = contentRef.current; if (!container || !content) return; @@ -61,7 +67,7 @@ export default function ZoomFrame({ height, children }: Props) { prevContentH.current = contentH; const cw = container.clientWidth; const ch = container.clientHeight; - const home = computeHome(cw, ch, contentH); + const home = computeHome(cw, ch, contentH, anchor); homeZoom.current = home; setZoom(home); }); @@ -113,12 +119,12 @@ export default function ZoomFrame({ height, children }: Props) { Math.abs(zoom.ty - h.ty) < 0.5; return ( -
+
(`/api/nodes/${stack}`, 5000); const { data: pool } = useApi('/api/pool', 5000); @@ -32,154 +31,261 @@ function DashboardView() { const nodeCount = nodes?.length ?? 0; const edgeCount = graph?.edges.length ?? 0; const nodeCountG = graph?.nodes.length ?? 0; + const registeredCount = graph?.nodes.filter(n => n.registered).length ?? 0; const proxyCount = graph ? new Set(graph.edges.filter(e => e.via_proxy).map(e => e.via_proxy!)).size : 0; const poolPct = pool ? ((pool.in_use / pool.total) * 100).toFixed(1) : null; + // sorted: registered first, then alpha + const sortedNodes = useMemo(() => { + if (!graph) return []; + return [...graph.nodes].sort((a, b) => { + if (a.registered !== b.registered) return a.registered ? -1 : 1; + return a.id.localeCompare(b.id); + }); + }, [graph]); + + // look up max_networks from services context for the services card + const maxNetByName = useMemo(() => { + const m = new Map(); + for (const s of services ?? []) m.set(s.name, s.max_networks); + return m; + }, [services]); + return ( <>
+ {/* 3 rows: stats (auto) | info section (220px) | topology (auto, grows with content) */} +
- {/* Active Connections — always-visible collapsible card with stats in header */} -
-
setConnectionsOpen(v => !v)} - style={{ cursor: 'pointer', userSelect: 'none' }} - > - Active Connections - - - {sessionCount} - sessions - - · - - {nodeCount} - nodes - - · - - {edgeCount} - edges - - {poolPct !== null && ( - <> - · + {/* ── Row 1: stat cards ── */} +
+
+
Sessions
+
{sessionCount}
+
active sessions
+
+
+
Services
+
+ {registeredCount}/{nodeCountG} +
+
{nodeCountG - registeredCount} unregistered
+
+
+
Pool
+
80 ? 'var(--amber)' : 'var(--t0)' }}> + {poolPct !== null ? `${poolPct}%` : '—'} +
+
{pool ? `${pool.in_use} / ${pool.total} in use` : 'loading…'}
+
+
+
Nodes
+
{nodeCount}
+
connected
+
+
+ + {/* ── Row 2: connections + services ── */} +
+ + {/* Active connections */} +
+
+ Active Connections + - {poolPct}% - pool + {edgeCount} + edges - - )} - {connectionsOpen ? '▾' : '▸'} - -
+ {proxyCount > 0 && ( + <> + · + {proxyCount} via proxy + + )} + +
+
+ {edgeCount === 0 ? ( +
+ No active connections +
+ ) : ( + + + + + + + + + + + + + + {graph!.edges.map((e, i) => { + const session = sessionByNetId.get(e.net_id); + return ( + dispatch({ type: 'EDGE_CLICKED', fromId: e.from, toId: e.to, edgeIndices: [i] })} + style={{ + cursor: 'pointer', + background: panel?.type === 'edge' && panel.edgeIndices.includes(i) + ? 'rgba(91,156,246,.07)' + : undefined, + }} + > + + + + + + + + + ); + })} + +
FromVia ProxyToNet IDClient NetServer NetSetup
{e.from} + {e.via_proxy ?? } + {e.to} + {e.via_proxy && chainByProxyNetId.has(e.net_id) + ? chainByProxyNetId.get(e.net_id)!.join(', ') + : e.net_id} + + {session?.client_net ?? } + + {session?.server_net ?? } + + {e.setup_ms > 0 ? `${e.setup_ms}ms` : '—'} +
+ )} +
+
- {connectionsOpen && ( - edgeCount === 0 ? ( -
- No active connections + {/* Services status */} +
+
+ Services + + {registeredCount} / {nodeCountG} registered +
- ) : ( - - - - - - - - - - - - - - {graph!.edges.map((e, i) => { - const session = sessionByNetId.get(e.net_id); +
+ {sortedNodes.length === 0 ? ( +
+ No services +
+ ) : ( + sortedNodes.map(node => { + const isHealthy = node.registered && node.active_replica_count > 0 && node.paused_replica_count === 0; + const isDegraded = node.registered && (node.active_replica_count === 0 || node.paused_replica_count > 0); + const dotColor = isHealthy ? 'var(--green)' : isDegraded ? 'var(--amber)' : 'var(--red)'; + const dotGlow = isHealthy + ? '0 0 5px rgba(52,211,153,.7)' + : isDegraded + ? '0 0 5px rgba(251,191,36,.7)' + : '0 0 5px rgba(248,113,113,.7)'; + const maxNet = maxNetByName.get(node.id); + const activeSessions = graph!.edges.filter(e => e.from === node.id || e.to === node.id).length; + return ( -
dispatch({ type: 'EDGE_CLICKED', fromId: e.from, toId: e.to, edgeIndices: [i] })} +
dispatch({ type: 'NODE_CLICKED', nodeId: node.id })} style={{ + display: 'flex', alignItems: 'center', gap: 10, + padding: '9px 16px', + borderBottom: '1px solid rgba(255,255,255,.03)', cursor: 'pointer', - background: panel?.type === 'edge' && panel.edgeIndices.includes(i) + background: panel?.type === 'node' && panel.nodeId === node.id ? 'rgba(91,156,246,.07)' : undefined, + transition: 'background .12s', }} + onMouseEnter={e => { if (!(panel?.type === 'node' && panel.nodeId === node.id)) (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,.025)'; }} + onMouseLeave={e => { if (!(panel?.type === 'node' && panel.nodeId === node.id)) (e.currentTarget as HTMLElement).style.background = ''; }} > -
- - - - - - - + + + {node.id} + + {node.entry_point && ( + + EP + + )} + + {node.active_replica_count} + {maxNet !== undefined + ? /{maxNet} + : /{node.replica_count} + } + + {activeSessions > 0 && ( + + {activeSessions}↔ + + )} + ); - })} - -
FromVia ProxyToNet IDClient NetServer NetSetup
{e.from} - {e.via_proxy ?? } - {e.to} - {e.via_proxy && chainByProxyNetId.has(e.net_id) - ? chainByProxyNetId.get(e.net_id)!.join(', ') - : e.net_id} - - {session?.client_net ?? } - - {session?.server_net ?? } - - {e.setup_ms > 0 ? `${e.setup_ms}ms` : '—'} -
- ) - )} -
+ }) + )} +
+
- {/* Full-width topology card */} -
-
- Service Topology - - {graph ? ( - <> - {nodeCountG} services - {proxyCount > 0 && {proxyCount} prox{proxyCount === 1 ? 'y' : 'ies'}} - {edgeCount} edges - - ) : 'loading…'} -
-
- {!graph && ( -
- loading topology… -
- )} - {graph && graph.nodes.length === 0 && ( -
- No services registered for stack {stack} + {/* ── Row 3: topology grows to fit content ── */} +
+
+ Service Topology + + {graph ? ( + <> + {nodeCountG} services + {proxyCount > 0 && {proxyCount} prox{proxyCount === 1 ? 'y' : 'ies'}} + {edgeCount} edges + + ) : 'loading…'} + +
+ +
+ {!graph && ( +
+ loading topology… +
+ )} + {graph && graph.nodes.length === 0 && ( +
+ No services registered for stack {stack} +
+ )} + {graph && graph.nodes.length > 0 && ( + + )} +
+ + {proxyCount > 0 && ( +
+ + + direct + + + + via proxy +
)} - {graph && graph.nodes.length > 0 && }
- {proxyCount > 0 && ( -
- - - direct - - - - via proxy - -
- )}
-
diff --git a/members/nullnet-server/ui/src/pages/Topology.tsx b/members/nullnet-server/ui/src/pages/Topology.tsx index 4586217..8335c78 100644 --- a/members/nullnet-server/ui/src/pages/Topology.tsx +++ b/members/nullnet-server/ui/src/pages/Topology.tsx @@ -1,140 +1,32 @@ -import { useMemo } from 'react'; import Layout from '../components/Layout'; import { useStack } from '../StackContext'; -import { TopologyProvider, useTopologyData, useTopologyUI } from '../components/topology/TopologyContext'; +import { TopologyProvider, useTopologyData } from '../components/topology/TopologyContext'; import TopologyGraph from '../components/topology/TopologyGraph'; import TopologyPanel from '../components/topology/TopologyPanel'; function TopologyView() { - const { graph, chains } = useTopologyData(); - const { panel, dispatch } = useTopologyUI(); - - const chainByProxyNetId = useMemo(() => { - const m = new Map(); - for (const c of chains ?? []) m.set(c.proxy_net_id, c.all_net_ids); - return m; - }, [chains]); + const { graph } = useTopologyData(); const { stack } = useStack(); - const nodeCount = graph?.nodes.length ?? 0; - const registeredCount = graph?.nodes.filter(n => n.registered).length ?? 0; - const edgeCount = graph?.edges.length ?? 0; - const proxyCount = graph - ? new Set(graph.edges.filter(e => e.via_proxy).map(e => e.via_proxy!)).size - : 0; - - return ( - <> -
-
-
-
Services
-
{registeredCount}/{nodeCount}
-
{nodeCount - registeredCount} unregistered
-
-
-
Active Edges
-
{edgeCount}
-
live connections
-
-
-
Entry Points
-
- {graph?.nodes.filter(n => n.entry_point).length ?? '—'} -
-
with timeout
-
-
- - -
-
- Service Topology - - {graph ? ( - <> - {nodeCount} services - {proxyCount > 0 && {proxyCount} prox{proxyCount === 1 ? 'y' : 'ies'}} - {edgeCount} edges - - ) : 'loading…'} - -
-
- {!graph && ( -
- loading topology… -
- )} - {graph && graph.nodes.length === 0 && ( -
- No services registered for stack {stack} -
- )} - {graph && graph.nodes.length > 0 && } -
- {proxyCount > 0 && ( -
- - - direct - - - - via proxy - -
- )} -
+ if (!graph) { + return ( +
+ loading topology… +
+ ); + } - {graph && graph.edges.length > 0 && ( -
-
- Active Connections -
- - - - - - - - - - - - {graph.edges.map((e, i) => ( - dispatch({ type: 'EDGE_CLICKED', fromId: e.from, toId: e.to, edgeIndices: [i] })} - style={{ - cursor: 'pointer', - background: panel?.type === 'edge' && panel.edgeIndices.includes(i) - ? 'rgba(91,156,246,.07)' - : undefined, - }} - > - - - - - - - ))} - -
FromVia ProxyToNet IDSetup
{e.from} - {e.via_proxy ?? } - {e.to} - {e.via_proxy && chainByProxyNetId.has(e.net_id) - ? chainByProxyNetId.get(e.net_id)!.join(', ') - : e.net_id} - - {e.setup_ms > 0 ? `${e.setup_ms}ms` : '—'} -
-
- )} + if (graph.nodes.length === 0) { + return ( +
+ No services registered for stack {stack}
+ ); + } + return ( + <> + ); @@ -144,7 +36,7 @@ export default function Topology() { const { stack } = useStack(); return ( live · SSE