diff --git a/members/nullnet-server/src/http_server/sessions.rs b/members/nullnet-server/src/http_server/sessions.rs index 537f22f..77a95a7 100644 --- a/members/nullnet-server/src/http_server/sessions.rs +++ b/members/nullnet-server/src/http_server/sessions.rs @@ -34,11 +34,11 @@ pub(super) async fn list_handler(State(state): State) -> impl IntoResp reg.all_clients_owned() .into_iter() .filter_map(|(c, ci, _, _)| { - let proxy_ip = c.is_proxy()?; + c.is_proxy()?; Some(SessionJson { id: ci.net_id(), network_id: ci.net_id(), - client_ip: proxy_ip.to_string(), + client_ip: c.name().to_string(), client_net: ci.client_net().to_string(), server_net: ci.server_net().to_string(), service: name.clone(), diff --git a/members/nullnet-server/ui/src/App.tsx b/members/nullnet-server/ui/src/App.tsx index 1bd4049..6054465 100644 --- a/members/nullnet-server/ui/src/App.tsx +++ b/members/nullnet-server/ui/src/App.tsx @@ -8,6 +8,7 @@ import Pool from './pages/Pool'; import Config from './pages/Config'; import Events from './pages/Events'; import Certificates from './pages/Certificates'; +import Topology from './pages/Topology'; export default function App() { return ( @@ -22,6 +23,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/members/nullnet-server/ui/src/components/Layout.tsx b/members/nullnet-server/ui/src/components/Layout.tsx index 71d3aba..73cc5ba 100644 --- a/members/nullnet-server/ui/src/components/Layout.tsx +++ b/members/nullnet-server/ui/src/components/Layout.tsx @@ -3,7 +3,7 @@ import { useStack } from '../StackContext'; import { useApi } from '../hooks/useApi'; import type { SessionJson } from '../types'; -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; @@ -16,7 +16,7 @@ const NAV = [ group: 'Overview', items: [ { id: 'dashboard', icon: '⊞', label: 'Dashboard', to: '/' }, - { id: 'topology', icon: '⬡', label: 'Topology', to: null }, + { 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 new file mode 100644 index 0000000..48e7014 --- /dev/null +++ b/members/nullnet-server/ui/src/components/topology/EdgePanel.tsx @@ -0,0 +1,43 @@ +import type { GraphEdgeJson } from '../../types'; +import { spRow, spKey, spCode } from './panelStyles'; + +interface Props { + edge: GraphEdgeJson; +} + +export default function EdgePanel({ edge }: Props) { + return ( + <> +
+
Type
+ + {edge.via_proxy ? 'Proxied' : 'Direct'} + +
+
+
From
+
{edge.from}
+
+
+
To
+
{edge.to}
+
+ {edge.via_proxy && ( +
+
Via Proxy
+
{edge.via_proxy}
+
+ )} +
+
Net ID
+
{edge.net_id}
+
+ {edge.setup_ms > 0 && ( +
+
Setup Time
+
{edge.setup_ms}ms
+
+ )} + + ); +} diff --git a/members/nullnet-server/ui/src/components/topology/InternetPanel.tsx b/members/nullnet-server/ui/src/components/topology/InternetPanel.tsx new file mode 100644 index 0000000..b3ea2c1 --- /dev/null +++ b/members/nullnet-server/ui/src/components/topology/InternetPanel.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react'; +import type { SessionJson } from '../../types'; +import { spRow, spKey, SpSep } from './panelStyles'; + +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 }); +} + +export default function InternetPanel({ sessions }: { sessions: SessionJson[] }) { + const [expanded, setExpanded] = useState(new Set()); + + if (sessions.length === 0) { + return ( +
+ No active clients +
+ ); + } + + const groups = groupBySubnet(sessions); + + return ( + <> +
+
Summary
+
+ + {sessions.length} + {' '}client{sessions.length !== 1 ? 's' : ''} + + · + + {groups.length} + {' '}subnet{groups.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; + }); + } + + return ( +
+
+ + ⬡ {label} + + + {groupSessions.length} + +
+ + {shown.map(s => ( +
+
+
+
+ {s.client_ip} +
+
{s.service}
+
+
+
+ net {s.network_id} +
+
{formatTime(s.created_at)}
+
+
+ ))} + + {hasMore && ( + + )} +
+ ); + })} + + ); +} diff --git a/members/nullnet-server/ui/src/components/topology/ProxyNodePanel.tsx b/members/nullnet-server/ui/src/components/topology/ProxyNodePanel.tsx new file mode 100644 index 0000000..43e17be --- /dev/null +++ b/members/nullnet-server/ui/src/components/topology/ProxyNodePanel.tsx @@ -0,0 +1,45 @@ +import type { GraphEdgeJson } from '../../types'; +import { spRow, spKey, spVal, spCode } from './panelStyles'; + +interface Props { + ip: string; + edges: GraphEdgeJson[]; +} + +export default function ProxyNodePanel({ ip, edges }: Props) { + const proxyEdges = edges.filter(e => e.via_proxy === ip); + const targets = [...new Set(proxyEdges.map(e => e.to))]; + + return ( + <> +
+
Type
+ Proxy entry +
+
+
IP Address
+
{ip}
+
+
+
Active Tunnels
+
{proxyEdges.length}
+
+ {targets.length > 0 && ( +
+
Routing to
+
+ {targets.map(t => { + const e = proxyEdges.find(e2 => e2.to === t)!; + return ( +
+ {t} + net {e.net_id} +
+ ); + })} +
+
+ )} + + ); +} diff --git a/members/nullnet-server/ui/src/components/topology/ServiceNodePanel.tsx b/members/nullnet-server/ui/src/components/topology/ServiceNodePanel.tsx new file mode 100644 index 0000000..49e5d51 --- /dev/null +++ b/members/nullnet-server/ui/src/components/topology/ServiceNodePanel.tsx @@ -0,0 +1,91 @@ +import type { ServiceJson } from '../../types'; +import type { TopoServiceNode } from './types'; +import { spRow, spKey, spVal, spCode, SpSep, SpSection } from './panelStyles'; + +interface Props { + node: TopoServiceNode; + service: ServiceJson | undefined; + onDepClick: (id: string) => void; +} + +export default function ServiceNodePanel({ node, service, onDepClick }: Props) { + const totalSessions = service?.replicas.reduce((s, r) => s + r.active_sessions, 0) ?? 0; + const deps = service ? [...new Set(service.proxy_dependencies.flat())] : []; + + return ( + <> +
+
Status
+
+ + {node.registered ? 'Registered' : 'Unregistered'} + + {node.entry_point && Entry Point} +
+
+ +
+
Active Sessions
+
{totalSessions}
+
+ +
+
Replicas
+
{node.active_replica_count} active / {node.replica_count} total
+
+ + {service?.timeout_secs != null && ( +
+
Timeout
+
{service.timeout_secs}s
+
+ )} + + {service?.max_networks != null && ( +
+
Max Networks
+
{service.max_networks}
+
+ )} + + {deps.length > 0 && ( +
+
Dependencies
+
+ {deps.map(dep => ( + + ))} +
+
+ )} + + {service && service.replicas.length > 0 && ( + <> + + Replicas + + + + {['Host', 'Port', 'Sess'].map(h => ( + + ))} + + + + {service.replicas.map((r, i) => ( + + + + + + ))} + +
{h}
{r.ip}{r.port}{r.active_sessions}
+ + )} + + + ); +} diff --git a/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx b/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx new file mode 100644 index 0000000..a1ed892 --- /dev/null +++ b/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx @@ -0,0 +1,172 @@ +import type { GraphJson } from '../../types'; +import { NODE_W, NODE_H, INET_W, INET_H, INTERNET_ID } from './types'; +import { buildTopoGraph, layoutNodes, svgDims, edgePath, inetEdgePath } from './layout'; + +interface Props { + graph: GraphJson; + showRegistered: boolean; + showUnregistered: boolean; + selectedNodeId: string | null; + selectedEdgeIdx: number | null; + onNodeClick: (id: string) => void; + onEdgeClick: (originalIdx: number) => void; +} + +export default function TopologyGraph({ + graph, + showRegistered, + showUnregistered, + selectedNodeId, + selectedEdgeIdx, + onNodeClick, + onEdgeClick, +}: Props) { + const { nodes: allNodes, edges: allEdges } = buildTopoGraph(graph); + + const nodes = allNodes.filter(n => { + if (n.kind !== 'service') return true; + if (n.registered && !showRegistered) return false; + if (!n.registered && !showUnregistered) return false; + return true; + }); + const visibleIds = new Set(nodes.map(n => n.id)); + const edges = allEdges.filter(e => visibleIds.has(e.from) && visibleIds.has(e.to)); + + const pos = layoutNodes(nodes, edges); + const { w, h } = svgDims(pos, nodes); + + return ( + + + + + + + + + + + + + + + + + + {/* Internet → Proxy edges (non-clickable, rendered first) */} + {edges.filter(e => e.isInternetEdge).map((e, i) => { + const fp = pos.get(e.from); + const tp = pos.get(e.to); + if (!fp || !tp) return null; + return ( + + ); + })} + + {/* Service / proxy edges */} + {edges.filter(e => !e.isInternetEdge).map((e, i) => { + const fp = pos.get(e.from); + const tp = pos.get(e.to); + if (!fp || !tp) return null; + const isSel = e.originalIdx === selectedEdgeIdx; + const stroke = isSel + ? 'rgba(91,156,246,.9)' + : e.isProxyHop ? 'rgba(251,191,36,.35)' : 'rgba(255,255,255,.18)'; + const arrowId = isSel ? 'arr-sel' : e.isProxyHop ? 'arr-proxy' : 'arr'; + const midX = (fp.x + tp.x) / 2 + NODE_W / 2; + const midY = (fp.y + tp.y) / 2 + NODE_H / 2; + return ( + onEdgeClick(e.originalIdx)} style={{ cursor: 'pointer' }}> + + + {e.setup_ms > 0 && !isSel && ( + + net {e.net_id} · {e.setup_ms}ms + + )} + + ); + })} + + {/* Nodes */} + {nodes.map(n => { + const p = pos.get(n.id); + if (!p) return null; + const isSel = n.id === selectedNodeId; + + if (n.kind === 'internet') { + return ( + onNodeClick(INTERNET_ID)} style={{ cursor: 'pointer' }}> + {isSel && ( + + )} + + + ⬡ internet + + + ); + } + + if (n.kind === 'proxy') { + return ( + onNodeClick(n.id)} style={{ cursor: 'pointer' }}> + {isSel && ( + + )} + + + proxy + {n.id} + + ); + } + + const color = n.registered ? '#34d399' : '#f87171'; + const strokeColor = n.registered ? 'rgba(52,211,153,.3)' : 'rgba(248,113,113,.2)'; + return ( + onNodeClick(n.id)} style={{ cursor: 'pointer' }}> + {isSel && ( + + )} + + + {n.id} + + {n.registered ? `${n.active_replica_count}/${n.replica_count} active` : 'unregistered'} + {n.entry_point ? ' · entry' : ''} + + + ); + })} + + ); +} diff --git a/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx b/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx new file mode 100644 index 0000000..b3fec3c --- /dev/null +++ b/members/nullnet-server/ui/src/components/topology/TopologyPanel.tsx @@ -0,0 +1,85 @@ +import type { GraphJson, ServiceJson, SessionJson } from '../../types'; +import type { PanelState } from './types'; +import ServiceNodePanel from './ServiceNodePanel'; +import ProxyNodePanel from './ProxyNodePanel'; +import EdgePanel from './EdgePanel'; +import InternetPanel from './InternetPanel'; + +interface Props { + panel: PanelState; + graph: GraphJson; + services: ServiceJson[] | null; + sessions: SessionJson[] | null; + onClose: () => void; + onNodeClick: (id: string) => void; +} + +export default function TopologyPanel({ panel, graph, services, sessions, onClose, onNodeClick }: Props) { + function getTitle(): string { + if (!panel) return '–'; + if (panel.type === 'internet') return 'Internet Clients'; + if (panel.type === 'edge') { + const e = graph.edges[panel.edgeIdx]; + return e ? `${e.from} → ${e.to}` : '–'; + } + return panel.nodeId; + } + + function renderContent() { + if (!panel) return null; + + if (panel.type === 'internet') { + return ; + } + + if (panel.type === 'edge') { + const e = graph.edges[panel.edgeIdx]; + return e ? : null; + } + + const { nodeId } = panel; + const graphNode = graph.nodes.find(n => n.id === nodeId); + if (graphNode) { + return ( + s.name === nodeId)} + onDepClick={onNodeClick} + /> + ); + } + if (graph.edges.some(e => e.via_proxy === nodeId)) { + return ; + } + return null; + } + + return ( +
+
+ + {getTitle()} + + +
+
+ {renderContent()} +
+
+ ); +} diff --git a/members/nullnet-server/ui/src/components/topology/layout.ts b/members/nullnet-server/ui/src/components/topology/layout.ts new file mode 100644 index 0000000..cc06183 --- /dev/null +++ b/members/nullnet-server/ui/src/components/topology/layout.ts @@ -0,0 +1,134 @@ +import type { GraphJson } from '../../types'; +import { NODE_W, NODE_H, H_GAP, V_GAP, INET_W, INET_H, INET_Y, INET_PROXY_GAP, INTERNET_ID } from './types'; +import type { Pos, TopoNode, TopoEdge } from './types'; + +export function buildTopoGraph(graph: GraphJson): { nodes: TopoNode[]; edges: TopoEdge[] } { + const nodes: TopoNode[] = graph.nodes.map(n => ({ ...n, kind: 'service' as const })); + const proxyIps = new Set(); + for (const e of graph.edges) { if (e.via_proxy) proxyIps.add(e.via_proxy); } + for (const ip of proxyIps) nodes.push({ kind: 'proxy', id: ip }); + + // Internet node — added whenever there are proxy nodes + if (proxyIps.size > 0) { + nodes.push({ kind: 'internet', id: INTERNET_ID }); + } + + const edges: TopoEdge[] = []; + + // Internet → Proxy edges + for (const ip of proxyIps) { + edges.push({ from: INTERNET_ID, to: ip, net_id: -1, setup_ms: 0, isProxyHop: false, isInternetEdge: true, originalIdx: -1 }); + } + + for (let idx = 0; idx < graph.edges.length; idx++) { + const e = graph.edges[idx]; + if (e.via_proxy) { + edges.push({ from: e.from, to: e.via_proxy, net_id: e.net_id, setup_ms: 0, isProxyHop: true, isInternetEdge: false, originalIdx: idx }); + edges.push({ from: e.via_proxy, to: e.to, net_id: e.net_id, setup_ms: e.setup_ms, isProxyHop: true, isInternetEdge: false, originalIdx: idx }); + } else { + edges.push({ from: e.from, to: e.to, net_id: e.net_id, setup_ms: e.setup_ms, isProxyHop: false, isInternetEdge: false, originalIdx: idx }); + } + } + return { nodes, edges }; +} + +export function layoutNodes(nodes: TopoNode[], edges: TopoEdge[]): Map { + const hasInternet = nodes.some(n => n.kind === 'internet'); + const proxyNodes = nodes.filter(n => n.kind === 'proxy'); + const serviceNodes = nodes.filter(n => n.kind === 'service'); + const pos = new Map(); + + // Proxy row y shifts down when internet node is present + const proxyRowY = hasInternet ? INET_Y + INET_H + INET_PROXY_GAP : V_GAP; + + proxyNodes.forEach((n, i) => { + pos.set(n.id, { x: H_GAP + i * (NODE_W + H_GAP), y: proxyRowY }); + }); + + // Internet node — centered over the proxy row + if (hasInternet && proxyNodes.length > 0) { + const proxyRowCenter = H_GAP + ((proxyNodes.length - 1) * (NODE_W + H_GAP)) / 2 + NODE_W / 2; + pos.set(INTERNET_ID, { x: proxyRowCenter - INET_W / 2, y: INET_Y }); + } + + const svcOffsetY = proxyNodes.length > 0 ? proxyRowY + NODE_H + V_GAP : V_GAP; + const svcSet = new Set(serviceNodes.map(n => n.id)); + const out = new Map>(); + const inc = new Map>(); + for (const n of serviceNodes) { out.set(n.id, new Set()); inc.set(n.id, new Set()); } + for (const e of edges) { + if (svcSet.has(e.from) && svcSet.has(e.to)) { + out.get(e.from)!.add(e.to); + inc.get(e.to)!.add(e.from); + } + } + + const layer = new Map(); + const q: string[] = serviceNodes.filter(n => !inc.get(n.id)?.size).map(n => n.id); + q.forEach(id => layer.set(id, 0)); + for (let i = 0; i < q.length; i++) { + const id = q[i], l = layer.get(id)!; + for (const next of out.get(id) ?? []) { + layer.set(next, Math.max(layer.get(next) ?? 0, l + 1)); + q.push(next); + } + } + for (const n of serviceNodes) { if (!layer.has(n.id)) layer.set(n.id, 0); } + + const byLayer = new Map(); + for (const [id, l] of layer) { + if (!byLayer.has(l)) byLayer.set(l, []); + byLayer.get(l)!.push(id); + } + for (const l of [...byLayer.keys()].sort((a, b) => a - b)) { + const row = byLayer.get(l)!.sort(); + row.forEach((id, i) => pos.set(id, { x: H_GAP + i * (NODE_W + H_GAP), y: svcOffsetY + l * (NODE_H + V_GAP) })); + } + return pos; +} + +export function svgDims(pos: Map, nodes: TopoNode[]): { w: number; h: number } { + const nodeById = new Map(nodes.map(n => [n.id, n])); + let maxX = 0, maxY = 0; + for (const [id, { x, y }] of pos.entries()) { + const n = nodeById.get(id); + const nw = n?.kind === 'internet' ? INET_W : NODE_W; + const nh = n?.kind === 'internet' ? INET_H : NODE_H; + maxX = Math.max(maxX, x + nw); + maxY = Math.max(maxY, y + nh); + } + return { w: maxX + H_GAP, h: maxY + V_GAP }; +} + +export function edgePath(from: Pos, to: Pos): string { + const fromMidY = from.y + NODE_H / 2; + const toMidY = to.y + NODE_H / 2; + if (toMidY > fromMidY + NODE_H) { + const x1 = from.x + NODE_W / 2, y1 = from.y + NODE_H; + const x2 = to.x + NODE_W / 2, y2 = to.y; + const cy = (y1 + y2) / 2; + return `M ${x1} ${y1} C ${x1} ${cy}, ${x2} ${cy}, ${x2} ${y2}`; + } + if (fromMidY > toMidY + NODE_H) { + const x1 = from.x + NODE_W / 2, y1 = from.y; + const x2 = to.x + NODE_W / 2, y2 = to.y + NODE_H; + const cy = (y1 + y2) / 2; + return `M ${x1} ${y1} C ${x1} ${cy}, ${x2} ${cy}, ${x2} ${y2}`; + } + const goRight = to.x >= from.x; + const x1 = goRight ? from.x + NODE_W : from.x; + const y1 = from.y + NODE_H / 2; + const x2 = goRight ? to.x : to.x + NODE_W; + const y2 = to.y + NODE_H / 2; + const cx = (x1 + x2) / 2; + return `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`; +} + +export function inetEdgePath(from: Pos, to: Pos): string { + const x1 = from.x + INET_W / 2; + const y1 = from.y + INET_H; + const x2 = to.x + NODE_W / 2; + const y2 = to.y; + const cy = (y1 + y2) / 2; + return `M ${x1} ${y1} C ${x1} ${cy}, ${x2} ${cy}, ${x2} ${y2}`; +} diff --git a/members/nullnet-server/ui/src/components/topology/panelStyles.tsx b/members/nullnet-server/ui/src/components/topology/panelStyles.tsx new file mode 100644 index 0000000..286abc0 --- /dev/null +++ b/members/nullnet-server/ui/src/components/topology/panelStyles.tsx @@ -0,0 +1,15 @@ +export const spRow = { marginBottom: 12 }; +export const spKey = { fontSize: 10, color: 'var(--t2)', marginBottom: 3, letterSpacing: '.04em', fontWeight: 500 }; +export const spVal = { fontSize: 12, color: 'var(--t0)' }; +export const spCode = { fontSize: 11, color: 'var(--cyan)', fontFamily: "'JetBrains Mono',monospace" }; +export function SpSep() { + return
; +} + +export function SpSection({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/members/nullnet-server/ui/src/components/topology/types.ts b/members/nullnet-server/ui/src/components/topology/types.ts new file mode 100644 index 0000000..41073f1 --- /dev/null +++ b/members/nullnet-server/ui/src/components/topology/types.ts @@ -0,0 +1,36 @@ +import type { GraphNodeJson } from '../../types'; + +export const NODE_W = 130; +export const NODE_H = 38; +export const H_GAP = 60; +export const V_GAP = 70; + +export const INET_W = 140; +export const INET_H = 22; +export const INET_Y = 35; // top y of internet node +export const INET_PROXY_GAP = 50; // gap between internet bottom and proxy top + +export const INTERNET_ID = 'internet'; + +export interface Pos { x: number; y: number } + +export type PanelState = + | null + | { type: 'node'; nodeId: string } + | { type: 'edge'; edgeIdx: number } + | { type: 'internet' }; + +export interface TopoServiceNode extends GraphNodeJson { kind: 'service' } +export interface TopoProxyNode { kind: 'proxy'; id: string } +export interface TopoInternetNode { kind: 'internet'; id: string } +export type TopoNode = TopoServiceNode | TopoProxyNode | TopoInternetNode; + +export interface TopoEdge { + from: string; + to: string; + net_id: number; + setup_ms: number; + isProxyHop: boolean; + isInternetEdge: boolean; + originalIdx: number; +} diff --git a/members/nullnet-server/ui/src/pages/Dashboard.tsx b/members/nullnet-server/ui/src/pages/Dashboard.tsx index 5146a1b..ad52814 100644 --- a/members/nullnet-server/ui/src/pages/Dashboard.tsx +++ b/members/nullnet-server/ui/src/pages/Dashboard.tsx @@ -1,7 +1,9 @@ import Layout from '../components/Layout'; import { useApi } from '../hooks/useApi'; import { useStack } from '../StackContext'; -import type { SessionJson, ServiceJson, NodeJson, PoolJson } from '../types'; +import type { SessionJson, ServiceJson, NodeJson, PoolJson, GraphJson } from '../types'; +import { buildTopoGraph, layoutNodes, svgDims, edgePath, inetEdgePath } from '../components/topology/layout'; +import { NODE_W, NODE_H, INET_W, INET_H, INTERNET_ID } from '../components/topology/types'; export default function Dashboard() { const { stack } = useStack(); @@ -9,6 +11,7 @@ export default function Dashboard() { const { data: services } = useApi(`/api/services/${stack}`, 5000); const { data: nodes } = useApi('/api/nodes', 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; @@ -58,36 +61,98 @@ export default function Dashboard() {
Topology - live graph coming soon + + live · 5s +
- - - - - - {services?.map((svc, i) => { - const total = Math.max(services.length, 1); - const x = 100 + (i / (total - 1 || 1)) * 500; - const y = i % 2 === 0 ? 80 : 160; - const color = svc.registered ? '#34d399' : '#f87171'; - const strokeColor = svc.registered ? 'rgba(52,211,153,.3)' : 'rgba(248,113,113,.2)'; - return ( - - - - {svc.name} - - {svc.registered ? `${svc.replicas.length} replica${svc.replicas.length !== 1 ? 's' : ''}` : 'unregistered'} - - - ); - })} - {!services && ( - loading topology… - )} - + {!graph && ( +
loading topology…
+ )} + {graph && (() => { + const { nodes: topoNodes, edges: topoEdges } = buildTopoGraph(graph); + const pos = layoutNodes(topoNodes, topoEdges); + const { w, h } = svgDims(pos, topoNodes); + return ( + + + + + + + + + + + + + + + {/* Internet → proxy edges */} + {topoEdges.filter(e => e.isInternetEdge).map((e, i) => { + const fp = pos.get(e.from); const tp = pos.get(e.to); + if (!fp || !tp) return null; + return ; + })} + + {/* Service / proxy edges */} + {topoEdges.filter(e => !e.isInternetEdge).map((e, i) => { + const fp = pos.get(e.from); const tp = pos.get(e.to); + if (!fp || !tp) return null; + return ; + })} + + {/* Nodes */} + {topoNodes.map(n => { + const p = pos.get(n.id); + if (!p) return null; + + if (n.kind === 'internet') return ( + + + + ⬡ internet + + + ); + + if (n.kind === 'proxy') return ( + + + + proxy + {n.id} + + ); + + const color = n.registered ? '#34d399' : '#f87171'; + const strokeColor = n.registered ? 'rgba(52,211,153,.3)' : 'rgba(248,113,113,.2)'; + return ( + + + + {n.id} + + {n.registered ? `${n.active_replica_count}/${n.replica_count} active` : 'unregistered'} + {n.entry_point ? ' · entry' : ''} + + + ); + })} + + ); + })()}
diff --git a/members/nullnet-server/ui/src/pages/Events.tsx b/members/nullnet-server/ui/src/pages/Events.tsx index fea3866..39c25e9 100644 --- a/members/nullnet-server/ui/src/pages/Events.tsx +++ b/members/nullnet-server/ui/src/pages/Events.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; import Layout from '../components/Layout'; import type { EventJson, Severity } from '../types'; @@ -158,9 +159,27 @@ const MAX_EVENTS = 500; const SEVERITIES: Severity[] = ['info', 'warning', 'error']; export default function Events() { + const [searchParams, setSearchParams] = useSearchParams(); + const kindFilter = searchParams.get('kind') ?? ''; + const severityFilter = (searchParams.get('severity') ?? '') as Severity | ''; + + function setKindFilter(kind: string) { + setSearchParams(prev => { + const next = new URLSearchParams(prev); + if (kind) next.set('kind', kind); else next.delete('kind'); + return next; + }, { replace: true }); + } + + function setSeverityFilter(s: Severity | '') { + setSearchParams(prev => { + const next = new URLSearchParams(prev); + if (s) next.set('severity', s); else next.delete('severity'); + return next; + }, { replace: true }); + } + const [events, setEvents] = useState([]); - const [kindFilter, setKindFilter] = useState(''); - const [severityFilter, setSeverityFilter] = useState(''); const [paused, setPaused] = useState(false); const [liveCount, setLiveCount] = useState(0); const pausedRef = useRef(paused); @@ -234,7 +253,7 @@ export default function Events() { diff --git a/members/nullnet-server/ui/src/pages/Topology.tsx b/members/nullnet-server/ui/src/pages/Topology.tsx new file mode 100644 index 0000000..804b8a0 --- /dev/null +++ b/members/nullnet-server/ui/src/pages/Topology.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect, useRef } from 'react'; +import Layout from '../components/Layout'; +import { useApi } from '../hooks/useApi'; +import { useStack } from '../StackContext'; +import type { GraphJson, ServiceJson, SessionJson } from '../types'; +import type { PanelState } from '../components/topology/types'; +import { INTERNET_ID } from '../components/topology/types'; +import TopologyGraph from '../components/topology/TopologyGraph'; +import TopologyPanel from '../components/topology/TopologyPanel'; + +export default function Topology() { + const { stack } = useStack(); + const { data: graph, refetch } = useApi(`/api/graph/${stack}`); + const { data: services } = useApi(`/api/services/${stack}`, 5000); + const { data: sessions } = useApi('/api/sessions', 5000); + + const [panel, setPanel] = useState(null); + const [showRegistered, setShowRegistered] = useState(true); + const [showUnregistered, setShowUnregistered] = useState(true); + + useEffect(() => { setPanel(null); }, [stack]); + + const refetchRef = useRef(refetch); + refetchRef.current = refetch; + useEffect(() => { + const es = new EventSource('/api/events/stream'); + es.onmessage = (ev) => { + try { + const event = JSON.parse(ev.data); + if (event.type === 'session_created' || event.type === 'session_torn_down') refetchRef.current(); + } catch { /* ignore */ } + }; + return () => es.close(); + }, []); + + 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; + + const selectedNodeId = + panel?.type === 'node' ? panel.nodeId : + panel?.type === 'internet' ? INTERNET_ID : + null; + const selectedEdgeIdx = panel?.type === 'edge' ? panel.edgeIdx : null; + + function handleNodeClick(nodeId: string) { + if (nodeId === INTERNET_ID) { + setPanel(p => p?.type === 'internet' ? null : { type: 'internet' }); + return; + } + setPanel(p => p?.type === 'node' && p.nodeId === nodeId ? null : { type: 'node', nodeId }); + } + function handleEdgeClick(edgeIdx: number) { + setPanel(p => p?.type === 'edge' && p.edgeIdx === edgeIdx ? null : { type: 'edge', edgeIdx }); + } + + return ( + + live · SSE + + } + > +
+
+
+
Services
+
{registeredCount}/{nodeCount}
+
{nodeCount - registeredCount} unregistered
+
+
+
Active Edges
+
{edgeCount}
+
live connections
+
+ +
+
Entry Points
+
+ {graph?.nodes.filter(n => n.entry_point).length ?? '—'} +
+
with timeout
+
+
+ +
+ FILTER + + + + Click node or edge to inspect +
+ +
+
+ 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 + +
+ )} +
+ + {graph && graph.edges.length > 0 && ( +
+
+ Active Connections +
+ + + + + + + + + + + + {graph.edges.map((e, i) => ( + handleEdgeClick(i)} + style={{ cursor: 'pointer', background: selectedEdgeIdx === i ? 'rgba(91,156,246,.07)' : undefined }} + > + + + + + + + ))} + +
FromVia ProxyToNet IDSetup
{e.from} + {e.via_proxy ?? } + {e.to}{e.net_id} + {e.setup_ms > 0 ? `${e.setup_ms}ms` : '—'} +
+
+ )} +
+ + {graph && ( + setPanel(null)} + onNodeClick={handleNodeClick} + /> + )} +
+ ); +} diff --git a/members/nullnet-server/ui/src/types.ts b/members/nullnet-server/ui/src/types.ts index e1e2fac..2a7b69d 100644 --- a/members/nullnet-server/ui/src/types.ts +++ b/members/nullnet-server/ui/src/types.ts @@ -107,3 +107,24 @@ export type EventJson = | WithSeverity & { type: 'certificate_installed'; domain: string } | WithSeverity & { type: 'certificate_renewed'; domain: string } | WithSeverity & { type: 'certificate_removed'; domain: string }; + +export interface GraphNodeJson { + id: string; + registered: boolean; + entry_point: boolean; + replica_count: number; + active_replica_count: number; +} + +export interface GraphEdgeJson { + from: string; + via_proxy?: string; + to: string; + net_id: number; + setup_ms: number; +} + +export interface GraphJson { + nodes: GraphNodeJson[]; + edges: GraphEdgeJson[]; +}