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: 4 additions & 0 deletions members/nullnet-server/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Events from './pages/Events';
import Certificates from './pages/Certificates';
import Topology from './pages/Topology';
import Users from './pages/Users';
import DebugTopology from './pages/DebugTopology';

export default function App() {
return (
Expand All @@ -20,6 +21,9 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
{/* Not linked from the sidebar — dev tool, renders pasted/loaded
JSON with no backend involved, so it doesn't need auth. */}
<Route path="/debug/topology" element={<DebugTopology />} />
<Route path="/" element={<RequireAuth><Dashboard /></RequireAuth>} />
<Route path="/services" element={<RequireAuth><Services /></RequireAuth>} />
<Route path="/nodes" element={<RequireAuth><Nodes /></RequireAuth>} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { LayoutMode } from './types';

interface Props {
mode: LayoutMode;
onChange: (mode: LayoutMode) => void;
}

const OPTIONS: { mode: LayoutMode; label: string }[] = [
{ mode: 'layered', label: 'layered' },
{ mode: 'matrix', label: 'matrix' },
];

// Small segmented control for switching between the available topology
// layouts (layered default, matrix) — styled to match ZoomFrame's existing
// "reset view" overlay button.
export default function LayoutModeToggle({ mode, onChange }: Props) {
return (
<div
style={{
display: 'flex',
background: 'rgba(255,255,255,.06)',
border: '1px solid rgba(255,255,255,.12)',
borderRadius: 5,
overflow: 'hidden',
}}
>
{OPTIONS.map(opt => {
const active = opt.mode === mode;
return (
<button
key={opt.mode}
onClick={() => onChange(opt.mode)}
style={{
background: active ? 'rgba(91,156,246,.25)' : 'transparent',
border: 'none',
color: active ? 'rgba(255,255,255,.9)' : 'rgba(255,255,255,.5)',
fontSize: 10,
padding: '3px 7px',
whiteSpace: 'nowrap',
cursor: 'pointer',
}}
>
{opt.label}
</button>
);
})}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { createContext, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
import type { ChainJson, GraphJson, ServiceJson, SessionJson } from '../../types';
import type { PanelState } from './types';
import { INTERNET_ID } from './types';
import type { LayoutMode, PanelState } from './types';
import { INTERNET_ID, LAYOUT_MODES } from './types';

const LAYOUT_MODE_STORAGE_KEY = 'topology-layout-mode';
import { useApi } from '../../hooks/useApi';

// ── Data context ──────────────────────────────────────────────────────────────
Expand All @@ -25,6 +27,7 @@ const TopologyDataContext = createContext<TopologyData>({
interface UIState {
panel: PanelState;
focusedClientIp: string | null;
layoutMode: LayoutMode;
}

export type UIAction =
Expand All @@ -33,11 +36,19 @@ export type UIAction =
| { type: 'PANEL_CLOSED' }
| { type: 'CLIENT_FOCUSED'; ip: string }
| { type: 'FOCUS_CLEARED' }
| { type: 'STACK_CHANGED' };
| { type: 'STACK_CHANGED' }
| { type: 'LAYOUT_MODE_CHANGED'; mode: LayoutMode };

function loadInitialLayoutMode(): LayoutMode {
if (typeof localStorage === 'undefined') return 'layered';
const stored = localStorage.getItem(LAYOUT_MODE_STORAGE_KEY);
return (LAYOUT_MODES as string[]).includes(stored ?? '') ? (stored as LayoutMode) : 'layered';
}

const initialUIState: UIState = {
panel: null,
focusedClientIp: null,
layoutMode: loadInitialLayoutMode(),
};

function uiReducer(state: UIState, action: UIAction): UIState {
Expand Down Expand Up @@ -78,6 +89,8 @@ function uiReducer(state: UIState, action: UIAction): UIState {
return { ...state, focusedClientIp: null };
case 'STACK_CHANGED':
return { ...state, panel: null, focusedClientIp: null };
case 'LAYOUT_MODE_CHANGED':
return { ...state, layoutMode: action.mode };
}
}

Expand Down Expand Up @@ -122,6 +135,12 @@ export function TopologyProvider({

const [uiState, dispatch] = useReducer(uiReducer, initialUIState);

// Persist the chosen layout mode across reloads (kept out of the reducer to
// keep it a pure function of state+action).
useEffect(() => {
localStorage.setItem(LAYOUT_MODE_STORAGE_KEY, uiState.layoutMode);
}, [uiState.layoutMode]);

// Reset panel and focus when the active stack changes (not on initial mount).
const prevStackRef = useRef(stack);
useEffect(() => {
Expand Down
55 changes: 41 additions & 14 deletions members/nullnet-server/ui/src/components/topology/TopologyGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useTopologyData, useTopologyUI } from './TopologyContext';
import TopologyGraphSvg from './TopologyGraphSvg';
import TopologyMatrix from './TopologyMatrix';
import ZoomFrame from './ZoomFrame';
import LayoutModeToggle from './LayoutModeToggle';

interface Props {
height?: number | string;
Expand All @@ -12,6 +14,7 @@ interface Props {
export default function TopologyGraph({ height = 520, fill, anchor, grow }: Props) {
const { graph } = useTopologyData();
const {
layoutMode,
selectedNodeId,
selectedEdgeKey,
focusedNetIds,
Expand All @@ -23,20 +26,44 @@ export default function TopologyGraph({ height = 520, fill, anchor, grow }: Prop
if (!graph) return null;

return (
<ZoomFrame height={height} fill={fill} anchor={anchor} grow={grow}>
<TopologyGraphSvg
graph={graph}
selectedNodeId={selectedNodeId}
selectedEdgeKey={selectedEdgeKey}
focusedNetIds={focusedNetIds}
focusedSessions={focusedSessions}
nodeIps={nodeIps}
onNodeClick={id => dispatch({ type: 'NODE_CLICKED', nodeId: id })}
onEdgeClick={(fromId, toId, edgeIndices) =>
dispatch({ type: 'EDGE_CLICKED', fromId, toId, edgeIndices })
}
onBgClick={() => dispatch({ type: 'PANEL_CLOSED' })}
/>
<ZoomFrame
height={height}
fill={fill}
anchor={anchor}
grow={grow}
overlay={
<LayoutModeToggle
mode={layoutMode}
onChange={mode => dispatch({ type: 'LAYOUT_MODE_CHANGED', mode })}
/>
}
>
{layoutMode === 'matrix' ? (
<TopologyMatrix
graph={graph}
selectedNodeId={selectedNodeId}
selectedEdgeKey={selectedEdgeKey}
onNodeClick={id => dispatch({ type: 'NODE_CLICKED', nodeId: id })}
onEdgeClick={(fromId, toId, edgeIndices) =>
dispatch({ type: 'EDGE_CLICKED', fromId, toId, edgeIndices })
}
onBgClick={() => dispatch({ type: 'PANEL_CLOSED' })}
/>
) : (
<TopologyGraphSvg
graph={graph}
selectedNodeId={selectedNodeId}
selectedEdgeKey={selectedEdgeKey}
focusedNetIds={focusedNetIds}
focusedSessions={focusedSessions}
nodeIps={nodeIps}
onNodeClick={id => dispatch({ type: 'NODE_CLICKED', nodeId: id })}
onEdgeClick={(fromId, toId, edgeIndices) =>
dispatch({ type: 'EDGE_CLICKED', fromId, toId, edgeIndices })
}
onBgClick={() => dispatch({ type: 'PANEL_CLOSED' })}
/>
)}
</ZoomFrame>
);
}
Loading