From 8a64e4cc7b3148065b82c667ed40535d9e24f0a1 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Thu, 25 Jun 2026 21:48:32 -0400 Subject: [PATCH] ui: topology interaction improvements - Click empty space to deselect a selected node - Auto-clear client focus when the focused client disconnects: sessions are refetched immediately on SSE session_torn_down events and a watcher clears the focus state as soon as the client leaves the list --- .../components/topology/TopologyContext.tsx | 21 ++++++++++++++++++- .../src/components/topology/TopologyGraph.tsx | 1 + .../components/topology/TopologyGraphSvg.tsx | 8 +++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/members/nullnet-server/ui/src/components/topology/TopologyContext.tsx b/members/nullnet-server/ui/src/components/topology/TopologyContext.tsx index 7ec51c2..0a12884 100644 --- a/members/nullnet-server/ui/src/components/topology/TopologyContext.tsx +++ b/members/nullnet-server/ui/src/components/topology/TopologyContext.tsx @@ -32,6 +32,7 @@ export type UIAction = | { type: 'EDGE_CLICKED'; fromId: string; toId: string; edgeIndices: number[] } | { type: 'PANEL_CLOSED' } | { type: 'CLIENT_FOCUSED'; ip: string } + | { type: 'FOCUS_CLEARED' } | { type: 'STACK_CHANGED' }; const initialUIState: UIState = { @@ -73,6 +74,8 @@ function uiReducer(state: UIState, action: UIAction): UIState { ...state, focusedClientIp: state.focusedClientIp === action.ip ? null : action.ip, }; + case 'FOCUS_CLEARED': + return { ...state, focusedClientIp: null }; case 'STACK_CHANGED': return { ...state, panel: null, focusedClientIp: null }; } @@ -111,7 +114,7 @@ export function TopologyProvider({ }) { const { data: graph, refetch } = useApi(`/api/graph/${stack}`); const { data: services } = useApi(`/api/services/${stack}`, 5000); - const { data: sessions } = useApi(`/api/sessions/${stack}`, 5000); + const { data: sessions, refetch: refetchSessions } = useApi(`/api/sessions/${stack}`, 5000); const { data: chains, refetch: refetchChains } = useApi(`/api/chains/${stack}`); const [uiState, dispatch] = useReducer(uiReducer, initialUIState); @@ -126,10 +129,15 @@ export function TopologyProvider({ }, [stack]); // SSE: re-fetch graph and chains whenever a session is created or torn down. + // Also clears client focus immediately when the focused client's session tears down. const refetchRef = useRef(refetch); refetchRef.current = refetch; const refetchChainsRef = useRef(refetchChains); refetchChainsRef.current = refetchChains; + const refetchSessionsRef = useRef(refetchSessions); + refetchSessionsRef.current = refetchSessions; + const focusedClientIpRef = useRef(uiState.focusedClientIp); + focusedClientIpRef.current = uiState.focusedClientIp; useEffect(() => { const es = new EventSource('/api/events/stream'); es.onmessage = (ev) => { @@ -138,12 +146,23 @@ export function TopologyProvider({ if (event.type === 'session_created' || event.type === 'session_torn_down') { refetchRef.current(); refetchChainsRef.current(); + refetchSessionsRef.current(); + } + if (event.type === 'session_torn_down' && event.client_ip === focusedClientIpRef.current) { + dispatch({ type: 'FOCUS_CLEARED' }); } } catch { /* ignore */ } }; return () => es.close(); }, []); + useEffect(() => { + if (!uiState.focusedClientIp || !sessions) return; + if (!sessions.some(s => s.client_ip === uiState.focusedClientIp)) { + dispatch({ type: 'FOCUS_CLEARED' }); + } + }, [sessions, uiState.focusedClientIp]); + const nodeIps = useMemo(() => { const m = new Map(); for (const svc of services ?? []) { diff --git a/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx b/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx index a0a62b9..9bec3f0 100644 --- a/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx +++ b/members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx @@ -28,6 +28,7 @@ export default function TopologyGraph() { onEdgeClick={(fromId, toId, edgeIndices) => dispatch({ type: 'EDGE_CLICKED', fromId, toId, edgeIndices }) } + onBgClick={() => dispatch({ type: 'PANEL_CLOSED' })} /> ); diff --git a/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx b/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx index 7316bb3..086dc8c 100644 --- a/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx +++ b/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx @@ -11,6 +11,7 @@ interface Props { nodeIps?: Map; onNodeClick?: (id: string) => void; onEdgeClick?: (fromId: string, toId: string, edgeIndices: number[]) => void; + onBgClick?: () => void; } export default function TopologyGraphSvg({ @@ -22,6 +23,7 @@ export default function TopologyGraphSvg({ nodeIps = new Map(), onNodeClick, onEdgeClick, + onBgClick, }: Props) { const { nodes, edges } = buildTopoGraph(graph); @@ -74,6 +76,8 @@ export default function TopologyGraphSvg({ + {onBgClick && } + {/* Internet → Proxy edges */} {edges.filter(e => e.isInternetEdge).map((e, i) => { const fp = pos.get(e.from); @@ -129,7 +133,7 @@ export default function TopologyGraphSvg({ return ( onEdgeClick(e.from, e.to, e.originalIndices) : undefined} + onClick={onEdgeClick ? (ev) => { ev.stopPropagation(); onEdgeClick(e.from, e.to, e.originalIndices); } : undefined} style={{ cursor: onEdgeClick ? 'pointer' : 'default', opacity: dimmed ? 0.1 : 1 }} > {onEdgeClick && } @@ -210,7 +214,7 @@ export default function TopologyGraphSvg({ if (!p) return null; const isSel = n.id === selectedNodeId; const nodeDimmed = focusedNetIds != null && !focusedNodeIds.has(n.id); - const clickHandler = onNodeClick ? () => onNodeClick(n.id) : undefined; + const clickHandler = onNodeClick ? (ev: { stopPropagation(): void }) => { ev.stopPropagation(); onNodeClick(n.id); } : undefined; const clipId = `nc-${ni}`; if (n.kind === 'internet') {