From 3d433b47c2afa5e5940dffff869a6c2c1b3b4eb0 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 24 Jun 2026 17:07:28 -0400 Subject: [PATCH 1/9] ui: show VNI badge on all focused chain edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only the proxy→entry-service edge rendered a VNI block, because the session lookup matched against focusedSessions (proxy-level sessions filtered by client IP). Chain hop edges have different net_ids that don't appear in those sessions, so `session` was always null for them and the badge was skipped. Fix: derive focusedNetId directly from the graph edge when no session match is found, so chain edges still get a VNI label (compact, without CIDR rows). The proxy edge keeps its full VNI + src/dst CIDR display. --- .../components/topology/TopologyGraphSvg.tsx | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx b/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx index b2c1a20..7316bb3 100644 --- a/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx +++ b/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx @@ -115,6 +115,13 @@ export default function TopologyGraphSvg({ 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; @@ -169,22 +176,26 @@ export default function TopologyGraphSvg({ )} - {session && ( + {focusedNetId !== null && ( - - VNI {session.network_id} - - - src {session.client_net} - - - dst {session.server_net} + VNI {focusedNetId} + {session && ( + <> + + src {session.client_net} + + + dst {session.server_net} + + + )} )} From 5d8be4541ae566b18538dfa6e88867b2452d4ed1 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 24 Jun 2026 17:07:35 -0400 Subject: [PATCH 2/9] ui: make side panels resizable by dragging left edge Adds a useDragResize hook that tracks mousedown/mousemove/mouseup on document to resize panels without cursor flicker. TopologyPanel and the Nodes detail panel (.dp) both get a 4px drag handle on their left edge with a subtle hover highlight. Width is constrained to [200, 560]px on both panels. --- .../src/components/topology/TopologyPanel.tsx | 7 ++-- .../ui/src/hooks/useDragResize.ts | 34 +++++++++++++++++++ members/nullnet-server/ui/src/index.css | 4 ++- members/nullnet-server/ui/src/pages/Nodes.tsx | 5 ++- 4 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 members/nullnet-server/ui/src/hooks/useDragResize.ts diff --git a/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx b/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx index d13200f..55ba73f 100644 --- a/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx +++ b/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx @@ -3,10 +3,12 @@ import ServiceNodePanel from './ServiceNodePanel'; import ProxyNodePanel from './ProxyNodePanel'; import EdgePanel from './EdgePanel'; import InternetPanel from './InternetPanel'; +import { useDragResize } from '../../hooks/useDragResize'; export default function TopologyPanel() { const { graph, services } = useTopologyData(); const { panel, dispatch } = useTopologyUI(); + const { width, onResizeStart } = useDragResize(268, 200, 560); if (!graph) return null; @@ -48,7 +50,7 @@ export default function TopologyPanel() { return (
+
{getTitle()} diff --git a/members/nullnet-server/ui/src/hooks/useDragResize.ts b/members/nullnet-server/ui/src/hooks/useDragResize.ts new file mode 100644 index 0000000..24d8d47 --- /dev/null +++ b/members/nullnet-server/ui/src/hooks/useDragResize.ts @@ -0,0 +1,34 @@ +import { useCallback, useRef, useState } from 'react'; + +export function useDragResize(initialWidth: number, min = 180, max = 600) { + const [width, setWidth] = useState(initialWidth); + const widthRef = useRef(width); + widthRef.current = width; + + const onResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = widthRef.current; + + const onMove = (ev: MouseEvent) => { + setWidth(Math.min(max, Math.max(min, startWidth + (startX - ev.clientX)))); + }; + + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, + [min, max], + ); + + return { width, onResizeStart }; +} diff --git a/members/nullnet-server/ui/src/index.css b/members/nullnet-server/ui/src/index.css index c8347e6..387586a 100644 --- a/members/nullnet-server/ui/src/index.css +++ b/members/nullnet-server/ui/src/index.css @@ -245,7 +245,9 @@ body { .nc-stat-v { font-size: 16px; font-weight: 700; font-family: 'Syne', sans-serif; letter-spacing: -.02em; } .nc-svcs { padding: 10px 18px; border-top: 1px solid var(--t3); display: flex; flex-wrap: wrap; gap: 5px; } .svc-tag { font-size: 10px; padding: 2px 9px; background: rgba(255,255,255,.05); border: 1px solid var(--t3); color: var(--t1); border-radius: 20px; } -.dp { width: 300px; background: rgba(3,5,8,.9); backdrop-filter: blur(28px); -webkit-backdrop-filter: blur(28px); border-left: 1px solid var(--gb); box-shadow: inset 0 1px 0 rgba(255,255,255,.07); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; } +.dp { width: 300px; background: rgba(3,5,8,.9); backdrop-filter: blur(28px); -webkit-backdrop-filter: blur(28px); border-left: 1px solid var(--gb); box-shadow: inset 0 1px 0 rgba(255,255,255,.07); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; position: relative; } +.drag-handle { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; cursor: col-resize; z-index: 1; transition: background .15s; } +.drag-handle:hover { background: rgba(255,255,255,.09); } .dp-head { padding: 16px 18px; border-bottom: 1px solid var(--t3); display: flex; align-items: center; justify-content: space-between; } .dp-title { font-size: 13px; font-weight: 700; } .dp-close { background: none; border: none; color: var(--t2); cursor: pointer; font-size: 18px; padding: 0; transition: color .1s; } diff --git a/members/nullnet-server/ui/src/pages/Nodes.tsx b/members/nullnet-server/ui/src/pages/Nodes.tsx index 5a2ec72..b00cc3f 100644 --- a/members/nullnet-server/ui/src/pages/Nodes.tsx +++ b/members/nullnet-server/ui/src/pages/Nodes.tsx @@ -3,11 +3,13 @@ import Layout from '../components/Layout'; import { useApi } from '../hooks/useApi'; import { useStack } from '../StackContext'; import type { NodeJson } from '../types'; +import { useDragResize } from '../hooks/useDragResize'; export default function Nodes() { const { stack } = useStack(); const { data: nodes, loading } = useApi(`/api/nodes/${stack}`, 5000); const [selected, setSelected] = useState(null); + const { width: dpWidth, onResizeStart } = useDragResize(300, 200, 560); const selectedNode = nodes?.find(n => n.ip === selected) ?? null; @@ -76,7 +78,8 @@ export default function Nodes() {
-
+
+
{selectedNode ? selectedNode.ip : '–'} {selectedNode && ( From 7f92ac11b4359d608c1df671ee8a7d0a30c6f4ee Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 24 Jun 2026 17:12:27 -0400 Subject: [PATCH 3/9] ui: add tooltip to side panel titles for truncated text --- .../nullnet-server/ui/src/components/topology/TopologyPanel.tsx | 2 +- members/nullnet-server/ui/src/pages/Nodes.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx b/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx index 55ba73f..ab1c54c 100644 --- a/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx +++ b/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx @@ -61,7 +61,7 @@ export default function TopologyPanel() { }}>
-
- {selectedNode ? selectedNode.ip : '–'} + {selectedNode ? selectedNode.ip : '–'} {selectedNode && ( )} From c802c362914b128628ac5bbe60f540f24f33cd10 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 24 Jun 2026 17:27:47 -0400 Subject: [PATCH 4/9] ui: flatten internet panel client list, drop subnet grouping --- .../src/components/topology/InternetPanel.tsx | 137 +++++------------- 1 file changed, 38 insertions(+), 99 deletions(-) diff --git a/members/nullnet-server/ui/src/components/topology/InternetPanel.tsx b/members/nullnet-server/ui/src/components/topology/InternetPanel.tsx index 2868f2c..bf64f89 100644 --- a/members/nullnet-server/ui/src/components/topology/InternetPanel.tsx +++ b/members/nullnet-server/ui/src/components/topology/InternetPanel.tsx @@ -1,25 +1,7 @@ -import { useMemo, useState } from 'react'; -import type { SessionJson } from '../../types'; +import { useMemo } from 'react'; import { spRow, spKey, SpSep } from './panelStyles'; import { useTopologyData, useTopologyUI } from './TopologyContext'; -const PREVIEW_LIMIT = 5; - -function groupBySubnet(sessions: SessionJson[]) { - const map = new Map(); - for (const s of sessions) { - const prefix = s.client_ip.split('.').slice(0, 3).join('.'); - if (!map.has(prefix)) map.set(prefix, []); - map.get(prefix)!.push(s); - } - return [...map.entries()] - .map(([prefix, group]) => ({ - label: prefix + '.x', - sessions: group.sort((a, b) => b.created_at - a.created_at), - })) - .sort((a, b) => b.sessions.length - a.sessions.length); -} - function formatTime(unix: number): string { return new Date(unix * 1000).toLocaleTimeString([], { hour12: false }); } @@ -33,9 +15,11 @@ export default function InternetPanel() { for (const c of chains ?? []) m.set(c.proxy_net_id, c.all_net_ids); return m; }, [chains]); - const [expanded, setExpanded] = useState(new Set()); - const sessionList = sessions ?? []; + const sessionList = useMemo( + () => [...(sessions ?? [])].sort((a, b) => b.created_at - a.created_at), + [sessions], + ); if (sessionList.length === 0) { return ( @@ -45,95 +29,50 @@ export default function InternetPanel() { ); } - const groups = groupBySubnet(sessionList); - return ( <>
Summary
-
- - {sessionList.length} - {' '}client{sessionList.length !== 1 ? 's' : ''} - - · - - {groups.length} - {' '}subnet{groups.length !== 1 ? 's' : ''} - +
+ {sessionList.length} + {' '}client{sessionList.length !== 1 ? 's' : ''}
- {groups.map(({ label, sessions: groupSessions }) => { - const isExpanded = expanded.has(label); - const shown = isExpanded ? groupSessions : groupSessions.slice(0, PREVIEW_LIMIT); - const hasMore = groupSessions.length > PREVIEW_LIMIT; - - function toggleExpand() { - setExpanded(prev => { - const next = new Set(prev); - if (isExpanded) next.delete(label); else next.add(label); - return next; - }); - } - + {sessionList.map(s => { + const isFocused = focusedClientIp === s.client_ip; return ( -
-
- - ⬡ {label} - - - {groupSessions.length} - +
dispatch({ type: 'CLIENT_FOCUSED', ip: s.client_ip })} + style={{ + display: 'flex', alignItems: 'center', gap: 8, + padding: '5px 6px', + marginBottom: 2, + borderRadius: 5, + border: `1px solid ${isFocused ? 'rgba(91,156,246,.45)' : 'transparent'}`, + background: isFocused ? 'rgba(91,156,246,.08)' : 'transparent', + cursor: 'pointer', + transition: 'background .12s, border-color .12s', + }} + > +
+
+
+ {s.client_ip} +
+
{s.service}
+
+
+
+ {chainByProxyNetId.has(s.network_id) + ? `nets ${chainByProxyNetId.get(s.network_id)!.join(', ')}` + : `net ${s.network_id}`} +
+
{formatTime(s.created_at)}
- - {shown.map(s => { - const isFocused = focusedClientIp === s.client_ip; - return ( -
dispatch({ type: 'CLIENT_FOCUSED', ip: s.client_ip })} - style={{ - display: 'flex', alignItems: 'center', gap: 8, - padding: '5px 6px', - marginBottom: 2, - borderRadius: 5, - border: `1px solid ${isFocused ? 'rgba(91,156,246,.45)' : 'transparent'}`, - background: isFocused ? 'rgba(91,156,246,.08)' : 'transparent', - cursor: 'pointer', - transition: 'background .12s, border-color .12s', - }} - > -
-
-
- {s.client_ip} -
-
{s.service}
-
-
-
- {chainByProxyNetId.has(s.network_id) - ? `nets ${chainByProxyNetId.get(s.network_id)!.join(', ')}` - : `net ${s.network_id}`} -
-
{formatTime(s.created_at)}
-
-
- ); - })} - - {hasMore && ( - - )}
); })} From 18255636b3b55143ffb970a7dccedb933820ada9 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 24 Jun 2026 17:37:59 -0400 Subject: [PATCH 5/9] ui: merge topology into dashboard, drop separate topology nav item Dashboard now hosts the full topology experience via TopologyProvider: compact 4-stat header row (sessions, nodes, edges, pool), full-width topology card with the active connections table pinned above the graph, and the interactive side panel. Removed Recent Sessions and Services sections (services have a dedicated tab). Removed Topology from nav. --- .../ui/src/components/Layout.tsx | 3 +- .../nullnet-server/ui/src/pages/Dashboard.tsx | 256 ++++++++++-------- .../nullnet-server/ui/src/pages/Topology.tsx | 2 +- 3 files changed, 142 insertions(+), 119 deletions(-) diff --git a/members/nullnet-server/ui/src/components/Layout.tsx b/members/nullnet-server/ui/src/components/Layout.tsx index 61f0818..6c3459f 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' | 'topology' | 'services' | 'nodes' | 'sessions' | 'pool' | 'config' | 'certificates' | 'events'; +type Page = 'dashboard' | 'services' | 'nodes' | 'sessions' | 'pool' | 'config' | 'certificates' | 'events'; interface Props { page: Page; @@ -17,7 +17,6 @@ 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/pages/Dashboard.tsx b/members/nullnet-server/ui/src/pages/Dashboard.tsx index 7ce38b7..6165b93 100644 --- a/members/nullnet-server/ui/src/pages/Dashboard.tsx +++ b/members/nullnet-server/ui/src/pages/Dashboard.tsx @@ -1,145 +1,169 @@ +import { useMemo } from 'react'; import Layout from '../components/Layout'; import { useApi } from '../hooks/useApi'; import { useStack } from '../StackContext'; -import type { SessionJson, ServiceJson, NodeJson, PoolJson, GraphJson } from '../types'; -import TopologyGraphSvg from '../components/topology/TopologyGraphSvg'; -import ZoomFrame from '../components/topology/ZoomFrame'; +import type { NodeJson, PoolJson } from '../types'; +import { TopologyProvider, useTopologyData, useTopologyUI } from '../components/topology/TopologyContext'; +import TopologyGraph from '../components/topology/TopologyGraph'; +import TopologyPanel from '../components/topology/TopologyPanel'; -export default function Dashboard() { +function DashboardView() { const { stack } = useStack(); - const { data: sessions } = useApi(`/api/sessions/${stack}`, 5000); - const { data: services } = useApi(`/api/services/${stack}`, 5000); + const { graph, sessions, chains } = useTopologyData(); + const { panel, dispatch } = useTopologyUI(); + const { data: nodes } = useApi(`/api/nodes/${stack}`, 5000); const { data: pool } = useApi('/api/pool', 5000); - const { data: graph } = useApi(`/api/graph/${stack}`, 5000); - const totalSvc = services?.length ?? 0; - const onlineSvc = services?.filter(s => s.registered).length ?? 0; + 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 sessionCount = sessions?.length ?? 0; const nodeCount = nodes?.length ?? 0; + const edgeCount = graph?.edges.length ?? 0; + const nodeCountG = graph?.nodes.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) : '—'; - const poolUsed = pool ? `${pool.in_use.toLocaleString()} of ${pool.total.toLocaleString()} IDs` : '—'; + const poolUsed = pool ? `${pool.in_use.toLocaleString()} / ${pool.total.toLocaleString()}` : '—'; + + const stats = [ + { label: 'Active Sessions', value: sessionCount, color: 'var(--cyan)', sub: 'isolated networks' }, + { label: 'Connected Nodes', value: nodeCount, color: 'var(--green)', sub: 'agent nodes' }, + { label: 'Active Edges', value: edgeCount, color: 'var(--blue)', sub: 'live connections' }, + { label: 'Pool Used', value: pool ? `${poolPct}%` : '—', color: 'var(--t0)', sub: poolUsed }, + ]; return ( - - live · 5s - - } - > + <>
-
-
-
Services Online
-
- {onlineSvc}/{totalSvc} -
-
{totalSvc - onlineSvc} unregistered
-
-
-
Active Sessions
-
{sessionCount}
-
isolated networks
-
-
-
Connected Nodes
-
{nodeCount}
-
agent nodes
-
-
-
Pool Used
-
- {pool ? <>{poolPct}% : '—'} + + {/* Compact stat row */} +
+ {stats.map(({ label, value, color, sub }) => ( +
+
{label}
+
{value}
+
{sub}
-
{poolUsed}
-
+ ))}
-
-
-
- Topology - - live · 5s - -
-
- {!graph && ( -
loading topology…
- )} - {graph && ( - - - - )} -
+ {/* Full-width topology card */} +
+
+ Service Topology + + {graph ? ( + <> + {nodeCountG} services + {proxyCount > 0 && {proxyCount} prox{proxyCount === 1 ? 'y' : 'ies'}} + {edgeCount} edges + + ) : 'loading…'} +
-
-
- Recent Sessions + {/* Active Connections — scrollable table pinned above the graph */} + {graph && graph.edges.length > 0 && ( +
+ + + + + + + + + + + + {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` : '—'} +
- {sessions && sessions.length > 0 ? ( - sessions.slice(0, 6).map(s => ( -
-
- {new Date(s.created_at * 1000).toLocaleTimeString()} - - NET {s.id} — {s.service} ← {s.client_ip} - -
- )) - ) : ( -
- {sessions === null ? 'Loading…' : 'No active sessions'} + )} + + {/* Graph */} +
+ {!graph && ( +
+ loading topology…
)} + {graph && graph.nodes.length === 0 && ( +
+ No services registered for stack {stack} +
+ )} + {graph && graph.nodes.length > 0 && }
-
-
-
- Services - {onlineSvc}/{totalSvc} online -
- - - - - - - - - - - - {services?.map(svc => ( - - - - - - - - ))} - -
NameStatusReplicasDependenciesTimeout
{svc.name} - - {svc.registered ? 'Online' : 'Offline'} - - - {svc.replicas.length} - - {svc.proxy_dependencies.flat().map((d, i) => ( - {d} - ))} - - {svc.timeout_secs != null ? `${svc.timeout_secs}s` : '—'} -
+ {/* Legend */} + {proxyCount > 0 && ( +
+ + + direct + + + + via proxy + +
+ )}
+
+ + + + ); +} + +export default function Dashboard() { + const { stack } = useStack(); + return ( + + live · SSE + + } + > + + + ); } diff --git a/members/nullnet-server/ui/src/pages/Topology.tsx b/members/nullnet-server/ui/src/pages/Topology.tsx index 88ce6d3..4586217 100644 --- a/members/nullnet-server/ui/src/pages/Topology.tsx +++ b/members/nullnet-server/ui/src/pages/Topology.tsx @@ -144,7 +144,7 @@ export default function Topology() { const { stack } = useStack(); return ( live · SSE From 88c6692d39d8cc2e31326f81c51fecc14a672023 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 24 Jun 2026 17:51:07 -0400 Subject: [PATCH 6/9] ui: render replica counts as a mini table in service node panel --- .../components/topology/ServiceNodePanel.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/members/nullnet-server/ui/src/components/topology/ServiceNodePanel.tsx b/members/nullnet-server/ui/src/components/topology/ServiceNodePanel.tsx index b7854f8..cebec77 100644 --- a/members/nullnet-server/ui/src/components/topology/ServiceNodePanel.tsx +++ b/members/nullnet-server/ui/src/components/topology/ServiceNodePanel.tsx @@ -31,7 +31,22 @@ export default function ServiceNodePanel({ node, service, onDepClick }: Props) {
Replicas
-
{node.active_replica_count} active / {node.paused_replica_count} paused / {node.replica_count} total
+ + + + {(['active', 'paused', 'total'] as const).map(h => ( + + ))} + + + + + + + + + +
{h}
{node.active_replica_count} 0 ? '#fbbf24' : 'var(--t2)' }}>{node.paused_replica_count}{node.replica_count}
{service?.timeout_secs != null && ( From 48a6be2d429edc13843644745b7b1d147a71d442 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 24 Jun 2026 17:57:19 -0400 Subject: [PATCH 7/9] ui: show client/server overlay network IPs in connections table and edge panel --- .../ui/src/components/topology/EdgePanel.tsx | 71 ++++++++++++------- .../nullnet-server/ui/src/pages/Dashboard.tsx | 69 +++++++++++------- 2 files changed, 89 insertions(+), 51 deletions(-) diff --git a/members/nullnet-server/ui/src/components/topology/EdgePanel.tsx b/members/nullnet-server/ui/src/components/topology/EdgePanel.tsx index 31e20eb..b1fd213 100644 --- a/members/nullnet-server/ui/src/components/topology/EdgePanel.tsx +++ b/members/nullnet-server/ui/src/components/topology/EdgePanel.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import type { GraphEdgeJson } from '../../types'; +import type { GraphEdgeJson, SessionJson } from '../../types'; import { spRow, spKey, spCode } from './panelStyles'; import { useTopologyData } from './TopologyContext'; @@ -8,7 +8,7 @@ interface Props { } export default function EdgePanel({ edges }: Props) { - const { chains } = useTopologyData(); + const { chains, sessions } = useTopologyData(); const chainByProxyNetId = useMemo(() => { const m = new Map(); @@ -16,6 +16,12 @@ export default function EdgePanel({ edges }: Props) { return m; }, [chains]); + const sessionByNetId = useMemo(() => { + const m = new Map(); + for (const s of sessions ?? []) m.set(s.network_id, s); + return m; + }, [sessions]); + if (edges.length === 0) return null; const first = edges[0]; @@ -46,31 +52,46 @@ export default function EdgePanel({ edges }: Props) { SESSIONS ({edges.length})
- {edges.map((e, i) => ( -
-
- - {e.via_proxy && chainByProxyNetId.has(e.net_id) - ? `nets ${chainByProxyNetId.get(e.net_id)!.join(', ')}` - : `net ${e.net_id}`} - - {e.setup_ms > 0 && ( - {e.setup_ms}ms setup + {edges.map((e, i) => { + const session = sessionByNetId.get(e.net_id); + return ( +
+
+ + {e.via_proxy && chainByProxyNetId.has(e.net_id) + ? `nets ${chainByProxyNetId.get(e.net_id)!.join(', ')}` + : `net ${e.net_id}`} + + {e.setup_ms > 0 && ( + {e.setup_ms}ms setup + )} +
+ {e.via_proxy && ( +
+ via {e.via_proxy} +
+ )} + {session && ( + + + {[['client', session.client_net], ['server', session.server_net]].map(([label, val]) => ( + + + + + ))} + +
{label}{val}
)}
- {e.via_proxy && ( -
- via {e.via_proxy} -
- )} -
- ))} + ); + })} ); } diff --git a/members/nullnet-server/ui/src/pages/Dashboard.tsx b/members/nullnet-server/ui/src/pages/Dashboard.tsx index 6165b93..7a97988 100644 --- a/members/nullnet-server/ui/src/pages/Dashboard.tsx +++ b/members/nullnet-server/ui/src/pages/Dashboard.tsx @@ -21,6 +21,12 @@ function DashboardView() { return m; }, [chains]); + const sessionByNetId = useMemo(() => { + const m = new Map[number]>(); + for (const s of sessions ?? []) m.set(s.network_id, s); + return m; + }, [sessions]); + const sessionCount = sessions?.length ?? 0; const nodeCount = nodes?.length ?? 0; const edgeCount = graph?.edges.length ?? 0; @@ -78,36 +84,47 @@ function DashboardView() { Via Proxy To Net ID + Client Net + Server Net Setup - {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, - }} - > - {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` : '—'} - - - ))} + {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, + }} + > + {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` : '—'} + + + ); + })}
From 456fc0ddd4a11430b321cf666485f52fd16958f7 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 24 Jun 2026 18:07:30 -0400 Subject: [PATCH 8/9] ui: extract active connections into collapsible card above topology --- .../nullnet-server/ui/src/pages/Dashboard.tsx | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/members/nullnet-server/ui/src/pages/Dashboard.tsx b/members/nullnet-server/ui/src/pages/Dashboard.tsx index 7a97988..8f4069d 100644 --- a/members/nullnet-server/ui/src/pages/Dashboard.tsx +++ b/members/nullnet-server/ui/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import Layout from '../components/Layout'; import { useApi } from '../hooks/useApi'; import { useStack } from '../StackContext'; @@ -11,6 +11,7 @@ function DashboardView() { const { stack } = useStack(); const { graph, sessions, chains } = useTopologyData(); const { panel, dispatch } = useTopologyUI(); + const [connectionsOpen, setConnectionsOpen] = useState(true); const { data: nodes } = useApi(`/api/nodes/${stack}`, 5000); const { data: pool } = useApi('/api/pool', 5000); @@ -59,26 +60,23 @@ function DashboardView() { ))}
- {/* Full-width topology card */} -
-
- Service Topology - - {graph ? ( - <> - {nodeCountG} services - {proxyCount > 0 && {proxyCount} prox{proxyCount === 1 ? 'y' : 'ies'}} - {edgeCount} edges - - ) : 'loading…'} - -
- - {/* Active Connections — scrollable table pinned above the graph */} - {graph && graph.edges.length > 0 && ( -
+ {/* Active Connections — collapsible card */} + {graph && graph.edges.length > 0 && ( +
+
setConnectionsOpen(v => !v)} + style={{ cursor: 'pointer', userSelect: 'none' }} + > + Active Connections + + {edgeCount} edge{edgeCount !== 1 ? 's' : ''} + {connectionsOpen ? '▾' : '▸'} + +
+ {connectionsOpen && ( - + @@ -127,8 +125,24 @@ function DashboardView() { })}
From Via Proxy
-
- )} + )} +
+ )} + + {/* Full-width topology card */} +
+
+ Service Topology + + {graph ? ( + <> + {nodeCountG} services + {proxyCount > 0 && {proxyCount} prox{proxyCount === 1 ? 'y' : 'ies'}} + {edgeCount} edges + + ) : 'loading…'} + +
{/* Graph */}
From e0927730b7e246b9348975636289ababd2b2d7b1 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 24 Jun 2026 23:25:46 -0400 Subject: [PATCH 9/9] ui: fold stats into connections card header, always show card with empty state --- .../nullnet-server/ui/src/pages/Dashboard.tsx | 86 ++++++++++--------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/members/nullnet-server/ui/src/pages/Dashboard.tsx b/members/nullnet-server/ui/src/pages/Dashboard.tsx index 8f4069d..2ae3296 100644 --- a/members/nullnet-server/ui/src/pages/Dashboard.tsx +++ b/members/nullnet-server/ui/src/pages/Dashboard.tsx @@ -35,46 +35,54 @@ function DashboardView() { 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) : '—'; - const poolUsed = pool ? `${pool.in_use.toLocaleString()} / ${pool.total.toLocaleString()}` : '—'; - - const stats = [ - { label: 'Active Sessions', value: sessionCount, color: 'var(--cyan)', sub: 'isolated networks' }, - { label: 'Connected Nodes', value: nodeCount, color: 'var(--green)', sub: 'agent nodes' }, - { label: 'Active Edges', value: edgeCount, color: 'var(--blue)', sub: 'live connections' }, - { label: 'Pool Used', value: pool ? `${poolPct}%` : '—', color: 'var(--t0)', sub: poolUsed }, - ]; + const poolPct = pool ? ((pool.in_use / pool.total) * 100).toFixed(1) : null; return ( <>
- {/* Compact stat row */} -
- {stats.map(({ label, value, color, sub }) => ( -
-
{label}
-
{value}
-
{sub}
-
- ))} -
- - {/* Active Connections — collapsible card */} - {graph && graph.edges.length > 0 && ( -
-
setConnectionsOpen(v => !v)} - style={{ cursor: 'pointer', userSelect: 'none' }} - > - Active Connections - - {edgeCount} edge{edgeCount !== 1 ? 's' : ''} - {connectionsOpen ? '▾' : '▸'} + {/* Active Connections — always-visible collapsible card with stats in header */} +
+
setConnectionsOpen(v => !v)} + style={{ cursor: 'pointer', userSelect: 'none' }} + > + Active Connections + + + {sessionCount} + sessions -
- {connectionsOpen && ( + · + + {nodeCount} + nodes + + · + + {edgeCount} + edges + + {poolPct !== null && ( + <> + · + + {poolPct}% + pool + + + )} + {connectionsOpen ? '▾' : '▸'} + +
+ + {connectionsOpen && ( + edgeCount === 0 ? ( +
+ No active connections +
+ ) : ( @@ -88,7 +96,7 @@ function DashboardView() { - {graph.edges.map((e, i) => { + {graph!.edges.map((e, i) => { const session = sessionByNetId.get(e.net_id); return (
- )} -
- )} + ) + )} +
{/* Full-width topology card */}
@@ -144,7 +152,6 @@ function DashboardView() {
- {/* Graph */}
{!graph && (
@@ -159,7 +166,6 @@ function DashboardView() { {graph && graph.nodes.length > 0 && }
- {/* Legend */} {proxyCount > 0 && (