Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<Set<string> | 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<string>();
const focusedNodeIds = new Set<string>();
if (focusedNetIds) {
Expand Down Expand Up @@ -82,14 +98,17 @@ export default function TopologyGraphSvg({
{onBgClick && <rect x={0} y={0} width={w} height={h} fill="transparent" onClick={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 (
<path
key={`inet-${i}`}
key={edgeKey}
d={inetEdgePath(fp, tp)}
fill="none"
stroke="rgba(91,156,246,.3)"
Expand All @@ -98,17 +117,20 @@ export default function TopologyGraphSvg({
markerEnd="url(#arr-inet)"
pointerEvents="none"
opacity={dimmed ? 0.1 : 1}
style={isNew ? { animation: 'edge-fade-in .9s ease-out' } : undefined}
/>
);
})}

{/* 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
Expand Down Expand Up @@ -138,17 +160,26 @@ export default function TopologyGraphSvg({

return (
<g
key={i}
key={edgeKey}
onClick={onEdgeClick ? (ev) => { ev.stopPropagation(); onEdgeClick(e.from, e.to, e.originalIndices); } : undefined}
style={{ cursor: onEdgeClick ? 'pointer' : 'default', opacity: dimmed ? 0.1 : 1 }}
>
{onEdgeClick && <path d={path} fill="none" stroke="transparent" strokeWidth="14" />}
<path
// pathLength normalizes stroke-dasharray/dashoffset to a 0–1 range for the
// draw-in animation below — only safe when the edge has no dash pattern of
// its own (dash === undefined); setting it on a dashed edge (proxy-hop "4 3",
// egress "2 4") would rescale that pattern relative to the whole path and
// stretch it into what looks like a solid line once the animation ends.
pathLength={dash === undefined ? '1' : undefined}
d={path} fill="none" stroke={stroke}
strokeWidth={isSel ? 2 : 1.5}
strokeDasharray={dash}
markerEnd={`url(#${arrowId})`}
pointerEvents="none"
style={isNew
? { animation: dash === undefined ? 'edge-draw-in .9s ease-out' : 'edge-fade-in .9s ease-out' }
: undefined}
/>

{/* Egress edge marker label (bows out to the right of both nodes) */}
Expand Down Expand Up @@ -250,6 +281,25 @@ export default function TopologyGraphSvg({
}

if (n.kind === 'proxy') {
if (n.placeholder) {
return (
<g key={n.id} style={{ opacity: nodeDimmed ? 0.12 : 1 }}>
<rect x={p.x} y={p.y} width={NODE_W} height={NODE_H} rx="8"
fill="rgba(255,255,255,.02)" stroke="rgba(255,255,255,.12)"
strokeWidth="1" strokeDasharray="5 3" />
<circle cx={p.x + 15} cy={p.y + 22} r="3.5" fill="none" stroke="rgba(255,255,255,.25)" strokeWidth="1.5" />
<g clipPath={`url(#${clipId})`} pointerEvents="none">
<defs>
<clipPath id={clipId}>
<rect x={p.x + 4} y={p.y + 2} width={NODE_W - 8} height={NODE_H - 4} />
</clipPath>
</defs>
<text x={p.x + 27} y={p.y + 20} fill="rgba(255,255,255,.35)" fontSize="9.5" fontWeight="500">proxy</text>
<text x={p.x + 27} y={p.y + 32} fill="rgba(255,255,255,.25)" fontSize="7.5">no active connections</text>
</g>
</g>
);
}
return (
<g key={n.id} onClick={clickHandler} style={{ cursor: onNodeClick ? 'pointer' : 'default', opacity: nodeDimmed ? 0.12 : 1 }}>
<defs>
Expand Down
10 changes: 6 additions & 4 deletions members/nullnet-server/ui/src/components/topology/layout.ts
Original file line number Diff line number Diff line change
@@ -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[] } {
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion members/nullnet-server/ui/src/components/topology/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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;

Expand Down
8 changes: 8 additions & 0 deletions members/nullnet-server/ui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down