From c5a7f343d6594f5b8cebf94a6539d7d401533a8c Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 22 Jul 2026 19:53:03 -0400 Subject: [PATCH] ui: always show Internet/proxy nodes in topology, animate new connections Internet and proxy nodes previously only appeared once a live proxy connection existed, making the topology view look empty by default. Now a placeholder proxy node is shown alongside Internet when idle, and newly-appeared edges play a one-time connect animation (draw-in for dash-less edges, fade-in for dashed ones so their stroke pattern isn't distorted) instead of just popping into place. --- .../components/topology/TopologyGraphSvg.tsx | 58 +++++++++++++++++-- .../ui/src/components/topology/layout.ts | 10 ++-- .../ui/src/components/topology/types.ts | 3 +- members/nullnet-server/ui/src/index.css | 8 +++ 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx b/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx index 93866bd..6bd96e9 100644 --- a/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx +++ b/members/nullnet-server/ui/src/components/topology/TopologyGraphSvg.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import type { GraphJson, SessionJson } from '../../types'; import { NODE_W, NODE_H, INET_W, INET_H, INTERNET_ID } from './types'; import { buildTopoGraph, layoutNodes, svgDims, edgePath, egressEdgePath, inetEdgePath, edgeLabelPoints } from './layout'; @@ -30,6 +31,21 @@ export default function TopologyGraphSvg({ const pos = layoutNodes(nodes, edges); const { w, h } = svgDims(pos, nodes); + // Track edge keys already seen so a connect animation plays only once per + // newly-appeared edge, never on initial mount and never on later re-renders + // of an edge that was already present (5s poll / SSE refetch keeps refiring + // renders for edges that haven't actually changed). + const seenEdgeKeysRef = useRef | null>(null); + const currentEdgeKeys = new Set(edges.map(e => `${e.from}\0${e.to}`)); + // Intentional: reads the ref's pre-commit value during render to know which + // edges are new-since-last-paint; it's only ever written from the effect + // below, after commit, so this is safe (not read-your-own-write). + const isNewEdge = (key: string) => seenEdgeKeysRef.current !== null && !seenEdgeKeysRef.current.has(key); + useEffect(() => { + seenEdgeKeysRef.current = currentEdgeKeys; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [graph]); + const focusedEdgeKeys = new Set(); const focusedNodeIds = new Set(); if (focusedNetIds) { @@ -82,14 +98,17 @@ export default function TopologyGraphSvg({ {onBgClick && } {/* Internet → Proxy edges */} - {edges.filter(e => e.isInternetEdge).map((e, i) => { + {/* eslint-disable-next-line react-hooks/refs -- see isNewEdge comment above */} + {edges.filter(e => e.isInternetEdge).map(e => { const fp = pos.get(e.from); const tp = pos.get(e.to); if (!fp || !tp) return null; const dimmed = focusedNetIds != null && !focusedNodeIds.has(e.to); + const edgeKey = `${e.from}\0${e.to}`; + const isNew = isNewEdge(edgeKey); return ( ); })} {/* Service / proxy edges */} - {edges.filter(e => !e.isInternetEdge).map((e, i) => { + {/* eslint-disable-next-line react-hooks/refs -- see isNewEdge comment above */} + {edges.filter(e => !e.isInternetEdge).map(e => { const fp = pos.get(e.from); const tp = pos.get(e.to); if (!fp || !tp) return null; const edgeKey = `${e.from}\0${e.to}`; const isSel = selectedEdgeKey === edgeKey; + const isNew = isNewEdge(edgeKey); const dimmed = focusedNetIds != null && !focusedEdgeKeys.has(edgeKey); const count = e.originalIndices.length; const stroke = isSel @@ -138,17 +160,26 @@ export default function TopologyGraphSvg({ return ( { ev.stopPropagation(); onEdgeClick(e.from, e.to, e.originalIndices); } : undefined} style={{ cursor: onEdgeClick ? 'pointer' : 'default', opacity: dimmed ? 0.1 : 1 }} > {onEdgeClick && } {/* Egress edge marker label (bows out to the right of both nodes) */} @@ -250,6 +281,25 @@ export default function TopologyGraphSvg({ } if (n.kind === 'proxy') { + if (n.placeholder) { + return ( + + + + + + + + + + proxy + no active connections + + + ); + } return ( diff --git a/members/nullnet-server/ui/src/components/topology/layout.ts b/members/nullnet-server/ui/src/components/topology/layout.ts index 80dff21..0037dee 100644 --- a/members/nullnet-server/ui/src/components/topology/layout.ts +++ b/members/nullnet-server/ui/src/components/topology/layout.ts @@ -1,5 +1,5 @@ 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 { NODE_W, NODE_H, H_GAP, V_GAP, INET_W, INET_H, INET_Y, INET_PROXY_GAP, INTERNET_ID, PLACEHOLDER_PROXY_ID } from './types'; import type { Pos, TopoNode, TopoEdge } from './types'; export function buildTopoGraph(graph: GraphJson): { nodes: TopoNode[]; edges: TopoEdge[] } { @@ -12,10 +12,12 @@ export function buildTopoGraph(graph: GraphJson): { nodes: TopoNode[]; edges: To } 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 }); + // Internet + proxy are always shown, even with no active connections — fall + // back to a non-interactive placeholder proxy node when none are live. + if (proxyIps.size === 0) { + nodes.push({ kind: 'proxy', id: PLACEHOLDER_PROXY_ID, placeholder: true }); } + nodes.push({ kind: 'internet', id: INTERNET_ID }); const inetEdges: TopoEdge[] = []; for (const ip of proxyIps) { diff --git a/members/nullnet-server/ui/src/components/topology/types.ts b/members/nullnet-server/ui/src/components/topology/types.ts index e1c7f3b..132ec74 100644 --- a/members/nullnet-server/ui/src/components/topology/types.ts +++ b/members/nullnet-server/ui/src/components/topology/types.ts @@ -11,6 +11,7 @@ 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 const PLACEHOLDER_PROXY_ID = '__no_proxy__'; export interface Pos { x: number; y: number } @@ -21,7 +22,7 @@ export type PanelState = | { type: 'internet' }; export interface TopoServiceNode extends GraphNodeJson { kind: 'service' } -export interface TopoProxyNode { kind: 'proxy'; id: string } +export interface TopoProxyNode { kind: 'proxy'; id: string; placeholder?: boolean } export interface TopoInternetNode { kind: 'internet'; id: string } export type TopoNode = TopoServiceNode | TopoProxyNode | TopoInternetNode; diff --git a/members/nullnet-server/ui/src/index.css b/members/nullnet-server/ui/src/index.css index 3c79586..b328db4 100644 --- a/members/nullnet-server/ui/src/index.css +++ b/members/nullnet-server/ui/src/index.css @@ -285,6 +285,14 @@ body { /* ── Animations ── */ @keyframes gp { 0%, 100% { opacity: 1; } 50% { opacity: .3; } } @keyframes flash { from { background: rgba(52,211,153,.08); } to { background: transparent; } } +@keyframes edge-draw-in { + from { stroke-dasharray: 1; stroke-dashoffset: 1; opacity: 0; } + to { stroke-dasharray: 1; stroke-dashoffset: 0; opacity: 1; } +} +@keyframes edge-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} /* ── Scrollbar ── */ ::-webkit-scrollbar { width: 4px; }