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/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/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 && ( - - )}
); })} 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 && ( 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} + + + )} )} diff --git a/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx b/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx index d13200f..ab1c54c 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/Dashboard.tsx b/members/nullnet-server/ui/src/pages/Dashboard.tsx index 7ce38b7..2ae3296 100644 --- a/members/nullnet-server/ui/src/pages/Dashboard.tsx +++ b/members/nullnet-server/ui/src/pages/Dashboard.tsx @@ -1,145 +1,206 @@ +import { useMemo, useState } 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 [connectionsOpen, setConnectionsOpen] = useState(true); + 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 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 poolPct = pool ? ((pool.in_use / pool.total) * 100).toFixed(1) : '—'; - const poolUsed = pool ? `${pool.in_use.toLocaleString()} of ${pool.total.toLocaleString()} IDs` : '—'; + 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) : null; return ( - - live · 5s - - } - > + <>
-
-
-
Services Online
-
- {onlineSvc}/{totalSvc} -
-
{totalSvc - onlineSvc} unregistered
-
-
-
Active Sessions
-
{sessionCount}
-
isolated networks
-
-
-
Connected Nodes
-
{nodeCount}
-
agent nodes
-
-
-
Pool Used
-
- {pool ? <>{poolPct}% : '—'} -
-
{poolUsed}
-
-
-
-
-
- Topology - - live · 5s + {/* Active Connections — always-visible collapsible card with stats in header */} +
+
setConnectionsOpen(v => !v)} + style={{ cursor: 'pointer', userSelect: 'none' }} + > + Active Connections + + + {sessionCount} + sessions -
-
- {!graph && ( -
loading topology…
- )} - {graph && ( - - - + · + + {nodeCount} + nodes + + · + + {edgeCount} + edges + + {poolPct !== null && ( + <> + · + + {poolPct}% + pool + + )} -
+ {connectionsOpen ? '▾' : '▸'} +
-
-
- Recent Sessions -
- {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'} + {connectionsOpen && ( + 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` : '—'} +
+ ) + )}
-
+ {/* Full-width topology card */} +
- Services - {onlineSvc}/{totalSvc} online + 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 && }
- - - - - - - - - - - - {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` : '—'} -
+ + {proxyCount > 0 && ( +
+ + + direct + + + + via proxy + +
+ )}
+
+ + + + ); +} + +export default function Dashboard() { + const { stack } = useStack(); + return ( + + live · SSE + + } + > + + + ); } diff --git a/members/nullnet-server/ui/src/pages/Nodes.tsx b/members/nullnet-server/ui/src/pages/Nodes.tsx index 5a2ec72..9b7e4f5 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,9 +78,10 @@ export default function Nodes() {
-
+
+
- {selectedNode ? selectedNode.ip : '–'} + {selectedNode ? selectedNode.ip : '–'} {selectedNode && ( )} 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