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
4 changes: 2 additions & 2 deletions members/nullnet-server/src/http_server/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ pub(super) async fn list_handler(State(state): State<AppState>) -> 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(),
Expand Down
2 changes: 2 additions & 0 deletions members/nullnet-server/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -22,6 +23,7 @@ export default function App() {
<Route path="/config" element={<Config />} />
<Route path="/certificates" element={<Certificates />} />
<Route path="/events" element={<Events />} />
<Route path="/topology" element={<Topology />} />
</Routes>
</BrowserRouter>
</StackProvider>
Expand Down
4 changes: 2 additions & 2 deletions members/nullnet-server/ui/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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' },
],
},
{
Expand Down
43 changes: 43 additions & 0 deletions members/nullnet-server/ui/src/components/topology/EdgePanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div style={spRow}>
<div style={spKey}>Type</div>
<span className={`badge ${edge.via_proxy ? 'b-amber' : 'b-blue'}`}>
{edge.via_proxy ? 'Proxied' : 'Direct'}
</span>
</div>
<div style={spRow}>
<div style={spKey}>From</div>
<div style={spCode}>{edge.from}</div>
</div>
<div style={spRow}>
<div style={spKey}>To</div>
<div style={spCode}>{edge.to}</div>
</div>
{edge.via_proxy && (
<div style={spRow}>
<div style={spKey}>Via Proxy</div>
<div style={{ ...spCode, color: '#fbbf24' }}>{edge.via_proxy}</div>
</div>
)}
<div style={spRow}>
<div style={spKey}>Net ID</div>
<div style={spCode}>{edge.net_id}</div>
</div>
{edge.setup_ms > 0 && (
<div style={spRow}>
<div style={spKey}>Setup Time</div>
<div style={spCode}>{edge.setup_ms}ms</div>
</div>
)}
</>
);
}
113 changes: 113 additions & 0 deletions members/nullnet-server/ui/src/components/topology/InternetPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<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 });
}

export default function InternetPanel({ sessions }: { sessions: SessionJson[] }) {
const [expanded, setExpanded] = useState(new Set<string>());

if (sessions.length === 0) {
return (
<div style={{ color: 'var(--t2)', fontSize: 11, textAlign: 'center', paddingTop: 24 }}>
No active clients
</div>
);
}

const groups = groupBySubnet(sessions);

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" }}>{sessions.length}</span>
{' '}client{sessions.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>
</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;
});
}

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>

{shown.map(s => (
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', borderBottom: '1px solid rgba(255,255,255,.03)' }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--blue)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontFamily: "'JetBrains Mono',monospace", color: 'var(--t0)', 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)' }}>
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>
);
})}
</>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div style={spRow}>
<div style={spKey}>Type</div>
<span className="badge b-amber">Proxy entry</span>
</div>
<div style={spRow}>
<div style={spKey}>IP Address</div>
<div style={spCode}>{ip}</div>
</div>
<div style={spRow}>
<div style={spKey}>Active Tunnels</div>
<div style={{ ...spVal, color: 'var(--cyan)' }}>{proxyEdges.length}</div>
</div>
{targets.length > 0 && (
<div style={spRow}>
<div style={spKey}>Routing to</div>
<div>
{targets.map(t => {
const e = proxyEdges.find(e2 => e2.to === t)!;
return (
<div key={t} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0', borderBottom: '1px solid var(--t3)' }}>
<span style={{ fontSize: 11, flex: 1, color: 'var(--t0)' }}>{t}</span>
<span className="badge b-blue" style={{ fontSize: '8.5px' }}>net {e.net_id}</span>
</div>
);
})}
</div>
</div>
)}
</>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div style={spRow}>
<div style={spKey}>Status</div>
<div>
<span className={`badge ${node.registered ? 'b-green' : 'b-dim'}`}>
{node.registered ? 'Registered' : 'Unregistered'}
</span>
{node.entry_point && <span className="badge b-blue" style={{ marginLeft: 6 }}>Entry Point</span>}
</div>
</div>

<div style={spRow}>
<div style={spKey}>Active Sessions</div>
<div style={{ ...spVal, color: 'var(--cyan)' }}>{totalSessions}</div>
</div>

<div style={spRow}>
<div style={spKey}>Replicas</div>
<div style={spVal}>{node.active_replica_count} active / {node.replica_count} total</div>
</div>

{service?.timeout_secs != null && (
<div style={spRow}>
<div style={spKey}>Timeout</div>
<div style={spCode}>{service.timeout_secs}s</div>
</div>
)}

{service?.max_networks != null && (
<div style={spRow}>
<div style={spKey}>Max Networks</div>
<div style={spCode}>{service.max_networks}</div>
</div>
)}

{deps.length > 0 && (
<div style={spRow}>
<div style={spKey}>Dependencies</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 2 }}>
{deps.map(dep => (
<button key={dep} onClick={() => onDepClick(dep)} className="dep-tag" style={{ cursor: 'pointer' }}>
{dep}
</button>
))}
</div>
</div>
)}

{service && service.replicas.length > 0 && (
<>
<SpSep />
<SpSection>Replicas</SpSection>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
{['Host', 'Port', 'Sess'].map(h => (
<th key={h} style={{ fontSize: 9, color: 'var(--t2)', padding: '3px 0', textAlign: 'left', borderBottom: '1px solid var(--t3)', letterSpacing: '.05em', fontWeight: 500, textTransform: 'uppercase' as const }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{service.replicas.map((r, i) => (
<tr key={i}>
<td style={{ fontSize: 11, padding: '5px 0', borderBottom: '1px solid rgba(255,255,255,.03)', color: 'var(--cyan)', fontFamily: "'JetBrains Mono',monospace", maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const }}>{r.ip}</td>
<td style={{ fontSize: 11, padding: '5px 0', borderBottom: '1px solid rgba(255,255,255,.03)', color: 'var(--t1)', fontFamily: "'JetBrains Mono',monospace" }}>{r.port}</td>
<td style={{ fontSize: 11, padding: '5px 0', borderBottom: '1px solid rgba(255,255,255,.03)', color: 'var(--t2)' }}>{r.active_sessions}</td>
</tr>
))}
</tbody>
</table>
</>
)}

</>
);
}
Loading