Skip to content
Merged
3 changes: 1 addition & 2 deletions members/nullnet-server/ui/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,7 +17,6 @@ const NAV = [
group: 'Overview',
items: [
{ id: 'dashboard', icon: '⊞', label: 'Dashboard', to: '/' },
{ id: 'topology', icon: '⬡', label: 'Topology', to: '/topology' },
],
},
{
Expand Down
71 changes: 46 additions & 25 deletions members/nullnet-server/ui/src/components/topology/EdgePanel.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -8,14 +8,20 @@ interface Props {
}

export default function EdgePanel({ edges }: Props) {
const { chains } = useTopologyData();
const { chains, sessions } = useTopologyData();

const chainByProxyNetId = useMemo(() => {
const m = new Map<number, number[]>();
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, SessionJson>();
for (const s of sessions ?? []) m.set(s.network_id, s);
return m;
}, [sessions]);

if (edges.length === 0) return null;
const first = edges[0];

Expand Down Expand Up @@ -46,31 +52,46 @@ export default function EdgePanel({ edges }: Props) {
SESSIONS ({edges.length})
</div>

{edges.map((e, i) => (
<div key={i} style={{
background: 'rgba(255,255,255,.03)',
border: '1px solid var(--gb)',
borderRadius: 6,
padding: '9px 11px',
marginBottom: 6,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, color: 'var(--cyan)' }}>
{e.via_proxy && chainByProxyNetId.has(e.net_id)
? `nets ${chainByProxyNetId.get(e.net_id)!.join(', ')}`
: `net ${e.net_id}`}
</span>
{e.setup_ms > 0 && (
<span style={{ fontSize: 10, color: 'var(--t2)' }}>{e.setup_ms}ms setup</span>
{edges.map((e, i) => {
const session = sessionByNetId.get(e.net_id);
return (
<div key={i} style={{
background: 'rgba(255,255,255,.03)',
border: '1px solid var(--gb)',
borderRadius: 6,
padding: '9px 11px',
marginBottom: 6,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: session || e.via_proxy ? 6 : 0 }}>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, color: 'var(--cyan)' }}>
{e.via_proxy && chainByProxyNetId.has(e.net_id)
? `nets ${chainByProxyNetId.get(e.net_id)!.join(', ')}`
: `net ${e.net_id}`}
</span>
{e.setup_ms > 0 && (
<span style={{ fontSize: 10, color: 'var(--t2)' }}>{e.setup_ms}ms setup</span>
)}
</div>
{e.via_proxy && (
<div style={{ fontSize: 10, color: '#fbbf24', fontFamily: "'JetBrains Mono',monospace", marginBottom: session ? 6 : 0 }}>
via {e.via_proxy}
</div>
)}
{session && (
<table style={{ borderCollapse: 'collapse', width: '100%', fontFamily: "'JetBrains Mono',monospace" }}>
<tbody>
{[['client', session.client_net], ['server', session.server_net]].map(([label, val]) => (
<tr key={label}>
<td style={{ fontSize: 9, color: 'var(--t2)', paddingRight: 8, paddingTop: 2, whiteSpace: 'nowrap', textTransform: 'uppercase', letterSpacing: '.05em', verticalAlign: 'top' }}>{label}</td>
<td style={{ fontSize: 10, color: 'var(--t1)', wordBreak: 'break-all' }}>{val}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{e.via_proxy && (
<div style={{ fontSize: 10, color: '#fbbf24', fontFamily: "'JetBrains Mono',monospace" }}>
via {e.via_proxy}
</div>
)}
</div>
))}
);
})}
</>
);
}
137 changes: 38 additions & 99 deletions members/nullnet-server/ui/src/components/topology/InternetPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<string, SessionJson[]>();
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 });
}
Expand All @@ -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<string>());

const sessionList = sessions ?? [];
const sessionList = useMemo(
() => [...(sessions ?? [])].sort((a, b) => b.created_at - a.created_at),
[sessions],
);

if (sessionList.length === 0) {
return (
Expand All @@ -45,95 +29,50 @@ export default function InternetPanel() {
);
}

const groups = groupBySubnet(sessionList);

return (
<>
<div style={spRow}>
<div style={spKey}>Summary</div>
<div style={{ fontSize: 12, color: 'var(--t0)', display: 'flex', gap: 8, alignItems: 'baseline' }}>
<span>
<span style={{ color: 'var(--cyan)', fontFamily: "'JetBrains Mono',monospace" }}>{sessionList.length}</span>
{' '}client{sessionList.length !== 1 ? 's' : ''}
</span>
<span style={{ color: 'var(--t3)' }}>·</span>
<span>
<span style={{ color: 'var(--blue)', fontFamily: "'JetBrains Mono',monospace" }}>{groups.length}</span>
{' '}subnet{groups.length !== 1 ? 's' : ''}
</span>
<div style={{ fontSize: 12, color: 'var(--t0)' }}>
<span style={{ color: 'var(--cyan)', fontFamily: "'JetBrains Mono',monospace" }}>{sessionList.length}</span>
{' '}client{sessionList.length !== 1 ? 's' : ''}
</div>
</div>

<SpSep />

{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 (
<div key={label} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
<span style={{ fontSize: 10, fontFamily: "'JetBrains Mono',monospace", color: 'var(--blue)', fontWeight: 600 }}>
⬡ {label}
</span>
<span style={{ fontSize: 9.5, color: 'var(--t2)', fontFamily: "'JetBrains Mono',monospace" }}>
{groupSessions.length}
</span>
<div
key={s.id}
onClick={() => 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',
}}
>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: isFocused ? 'var(--blue)' : 'rgba(91,156,246,.4)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontFamily: "'JetBrains Mono',monospace", color: isFocused ? 'var(--t0)' : 'var(--t1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.client_ip}
</div>
<div style={{ fontSize: 9.5, color: 'var(--t2)' }}>{s.service}</div>
</div>
<div style={{ flexShrink: 0, textAlign: 'right' }}>
<div style={{ fontSize: 9.5, fontFamily: "'JetBrains Mono',monospace", color: 'var(--cyan)' }}>
{chainByProxyNetId.has(s.network_id)
? `nets ${chainByProxyNetId.get(s.network_id)!.join(', ')}`
: `net ${s.network_id}`}
</div>
<div style={{ fontSize: 9, color: 'var(--t2)' }}>{formatTime(s.created_at)}</div>
</div>

{shown.map(s => {
const isFocused = focusedClientIp === s.client_ip;
return (
<div
key={s.id}
onClick={() => 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',
}}
>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: isFocused ? 'var(--blue)' : 'rgba(91,156,246,.4)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontFamily: "'JetBrains Mono',monospace", color: isFocused ? 'var(--t0)' : 'var(--t1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const }}>
{s.client_ip}
</div>
<div style={{ fontSize: 9.5, color: 'var(--t2)' }}>{s.service}</div>
</div>
<div style={{ flexShrink: 0, textAlign: 'right' as const }}>
<div style={{ fontSize: 9.5, fontFamily: "'JetBrains Mono',monospace", color: 'var(--cyan)' }}>
{chainByProxyNetId.has(s.network_id)
? `nets ${chainByProxyNetId.get(s.network_id)!.join(', ')}`
: `net ${s.network_id}`}
</div>
<div style={{ fontSize: 9, color: 'var(--t2)' }}>{formatTime(s.created_at)}</div>
</div>
</div>
);
})}

{hasMore && (
<button
onClick={toggleExpand}
style={{ display: 'block', width: '100%', padding: '5px 0', background: 'none', border: 'none', borderTop: '1px solid var(--t3)', color: 'var(--t2)', fontSize: 10, cursor: 'pointer', textAlign: 'left' as const, fontFamily: 'inherit' }}
>
{isExpanded ? 'Show less' : `+ ${groupSessions.length - PREVIEW_LIMIT} more`}
</button>
)}
</div>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,22 @@ export default function ServiceNodePanel({ node, service, onDepClick }: Props) {

<div style={spRow}>
<div style={spKey}>Replicas</div>
<div style={spVal}>{node.active_replica_count} active / {node.paused_replica_count} paused / {node.replica_count} total</div>
<table style={{ borderCollapse: 'collapse', fontSize: 11, fontFamily: "'JetBrains Mono',monospace" }}>
<thead>
<tr>
{(['active', 'paused', 'total'] as const).map(h => (
<th key={h} style={{ fontSize: 9, color: 'var(--t2)', paddingBottom: 3, paddingRight: 14, textAlign: 'right', letterSpacing: '.05em', fontWeight: 500, textTransform: 'uppercase', fontFamily: 'inherit' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td style={{ paddingRight: 14, textAlign: 'right', color: 'var(--green)' }}>{node.active_replica_count}</td>
<td style={{ paddingRight: 14, textAlign: 'right', color: node.paused_replica_count > 0 ? '#fbbf24' : 'var(--t2)' }}>{node.paused_replica_count}</td>
<td style={{ textAlign: 'right', color: 'var(--t1)' }}>{node.replica_count}</td>
</tr>
</tbody>
</table>
</div>

{service?.timeout_secs != null && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -169,22 +176,26 @@ export default function TopologyGraphSvg({
</text>
</g>
)}
{session && (
{focusedNetId !== null && (
<g>
<rect x={lp.mid.x - 60} y={lp.mid.y - 15} width={120} height={32} rx="4"
<rect x={lp.mid.x - 60} y={lp.mid.y - 15} width={120} height={session ? 32 : 16} rx="4"
fill="rgba(3,5,8,.88)" stroke="rgba(255,255,255,.07)" />
<text x={lp.mid.x} y={lp.mid.y - 6} textAnchor="middle"
fill="rgba(91,156,246,.9)" fontSize="8" fontWeight="600">
VNI {session.network_id}
</text>
<text x={lp.mid.x} y={lp.mid.y + 4} textAnchor="middle"
fill="rgba(255,255,255,.5)" fontSize="7" fontFamily="'JetBrains Mono',monospace">
<tspan fill="rgba(255,255,255,.3)">src </tspan>{session.client_net}
</text>
<text x={lp.mid.x} y={lp.mid.y + 13} textAnchor="middle"
fill="rgba(255,255,255,.5)" fontSize="7" fontFamily="'JetBrains Mono',monospace">
<tspan fill="rgba(255,255,255,.3)">dst </tspan>{session.server_net}
VNI {focusedNetId}
</text>
{session && (
<>
<text x={lp.mid.x} y={lp.mid.y + 4} textAnchor="middle"
fill="rgba(255,255,255,.5)" fontSize="7" fontFamily="'JetBrains Mono',monospace">
<tspan fill="rgba(255,255,255,.3)">src </tspan>{session.client_net}
</text>
<text x={lp.mid.x} y={lp.mid.y + 13} textAnchor="middle"
fill="rgba(255,255,255,.5)" fontSize="7" fontFamily="'JetBrains Mono',monospace">
<tspan fill="rgba(255,255,255,.3)">dst </tspan>{session.server_net}
</text>
</>
)}
</g>
)}
</g>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -48,7 +50,7 @@ export default function TopologyPanel() {

return (
<div style={{
position: 'fixed', top: 48, right: 0, bottom: 0, width: 268,
position: 'fixed', top: 48, right: 0, bottom: 0, width,
background: 'rgba(3,5,8,.95)',
backdropFilter: 'blur(28px)', WebkitBackdropFilter: 'blur(28px)',
borderLeft: '1px solid var(--gb)',
Expand All @@ -57,11 +59,12 @@ export default function TopologyPanel() {
transition: 'transform .22s cubic-bezier(.4,0,.2,1)',
zIndex: 50,
}}>
<div className="drag-handle" onMouseDown={onResizeStart} />
<div style={{ padding: '14px 18px', borderBottom: '1px solid var(--t3)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<span style={{
<span title={getTitle()} style={{
fontSize: 13, fontWeight: 600, color: 'var(--t0)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
maxWidth: 210, fontFamily: "'JetBrains Mono',monospace",
maxWidth: width - 58, fontFamily: "'JetBrains Mono',monospace",
}}>
{getTitle()}
</span>
Expand Down
Loading