merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

@@ -0,0 +1,281 @@
import React, { forwardRef, useCallback, useMemo } from 'react';
import { RotateCcw, LayoutGrid, ZoomIn, ZoomOut } from '../../icons';
import NetBox from './NetBox';
import NodeCard from './NodeCard';
import type { Net, MazeNode, Edge } from './types';
import type { Selection } from './Inspector';
import type { ResizeHandle } from './useMazeInteraction';
interface Props {
nets: Net[];
nodes: MazeNode[];
edges: Edge[];
deployed: boolean;
selection: Selection;
setSelection: (s: Selection) => void;
pan: { x: number; y: number };
zoom: number;
dropTargetId: string | null;
dragging: boolean;
edgeDraw: { fromX: number; fromY: number; toX: number; toY: number; hoverTarget: string | null } | null;
onCanvasMouseDown: (e: React.MouseEvent) => void;
onNodeMouseDown: (id: string) => (e: React.MouseEvent) => void;
onNetMouseDown: (id: string) => (e: React.MouseEvent) => void;
onNetResizeMouseDown: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void;
onPortMouseDown: (id: string) => (e: React.MouseEvent) => void;
onNodeContextMenu?: (id: string) => (e: React.MouseEvent) => void;
onNetContextMenu?: (id: string) => (e: React.MouseEvent) => void;
onEdgeContextMenu?: (id: string) => (e: React.MouseEvent) => void;
onCanvasContextMenu?: (e: React.MouseEvent) => void;
onResetView?: () => void;
onAutoLayout?: () => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
sseConnected?: boolean;
lastEventAt?: Date | null;
onSelectService?: (nodeId: string, slug: string) => void;
panLayerRef?: React.RefObject<HTMLDivElement | null>;
gridPatternRef?: React.RefObject<SVGPatternElement | null>;
}
const fmtTime = (d: Date) =>
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
const NODE_W = 140;
const NODE_HEAD_H = 22;
const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
{ nets, nodes, edges, deployed, selection, setSelection, pan, zoom, dropTargetId, dragging, edgeDraw,
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu,
onResetView, onAutoLayout, onZoomIn, onZoomOut, sseConnected, lastEventAt, onSelectService,
panLayerRef, gridPatternRef },
ref,
) {
const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
// Pre-indexed node lookup so edge rendering is O(E) instead of
// O(E·N) from the prior `nodes.find(...)` inside the edge loop.
const nodeById = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
const absPos = (node: MazeNode) => {
const net = netById.get(node.netId);
return { x: (net?.x ?? 0) + node.x, y: (net?.y ?? 0) + node.y };
};
// Stable per-kind selection callbacks so React.memo on children
// (NetBox/NodeCard) can actually short-circuit re-renders instead
// of seeing a fresh closure on every Canvas render.
const selectNet = useCallback((id: string) => setSelection({ type: 'net', id }), [setSelection]);
const selectNode = useCallback((id: string) => setSelection({ type: 'node', id }), [setSelection]);
// Flowing-dash edge animation is the single most expensive thing
// on the canvas — each animated <path> invalidates its bounding
// box every frame, and inter-LAN paths are long so the invalidated
// rects overlap most of the viewport. Past ~60 edges the compositor
// spends every frame repainting. Drop the animation class above
// the threshold; edges stay fully visible, just static.
const ANIMATE_EDGE_LIMIT = 60;
const animateEdges = edges.length <= ANIMATE_EDGE_LIMIT;
const activeNetIds = useMemo(() => {
const nodeNet = new Map(nodes.map((n) => [n.id, n.netId]));
const ids = new Set<string>();
for (const e of edges) {
const a = nodeNet.get(e.from); const b = nodeNet.get(e.to);
if (a) ids.add(a); if (b) ids.add(b);
}
return ids;
}, [nodes, edges]);
const selNetId = selection?.type === 'net' ? selection.id : null;
const selNodeId = selection?.type === 'node' ? selection.id
: selection?.type === 'service' ? selection.nodeId : null;
const selEdgeId = selection?.type === 'edge' ? selection.id : null;
const selServiceNodeId = selection?.type === 'service' ? selection.nodeId : null;
const selServiceSlug = selection?.type === 'service' ? selection.id : null;
return (
<div
ref={ref}
className="maze-canvas-wrap"
onMouseDown={(e) => {
if (e.target === e.currentTarget) setSelection(null);
onCanvasMouseDown(e);
}}
onContextMenu={(e) => {
if (e.target === e.currentTarget && onCanvasContextMenu) onCanvasContextMenu(e);
}}
style={{ cursor: dragging ? 'grabbing' : 'grab' }}
>
<div className="maze-grid-bg">
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern
ref={gridPatternRef ?? null}
id="maze-grid-pat"
x={pan.x}
y={pan.y}
width={40 * zoom}
height={40 * zoom}
patternUnits="userSpaceOnUse"
>
<path
d={`M ${40 * zoom} 0 L 0 0 0 ${40 * zoom}`}
fill="none"
stroke="var(--grid-line)"
strokeWidth="1"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#maze-grid-pat)" />
</svg>
</div>
<div
ref={panLayerRef ?? null}
className="maze-pan-layer"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: '0 0',
}}
>
<svg className="maze-svg" overflow="visible">
<defs>
<marker id="arrow-matrix" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#00ff41" />
</marker>
<marker id="arrow-violet" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#ee82ee" />
</marker>
<marker id="arrow-alert" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#ff4141" />
</marker>
</defs>
{edges.map((e) => {
const from = nodeById.get(e.from);
const to = nodeById.get(e.to);
if (!from || !to) return null;
const a = absPos(from); const b = absPos(to);
const x1 = a.x + NODE_W, y1 = a.y + NODE_HEAD_H;
const x2 = b.x, y2 = b.y + NODE_HEAD_H;
const cx = (x1 + x2) / 2;
const d = `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`;
const klass = e.traffic === 'hot' ? 'hot' : e.traffic === 'active' ? 'active' : '';
const marker = e.traffic === 'hot' ? 'arrow-alert' : e.traffic === 'active' ? 'arrow-violet' : 'arrow-matrix';
const isSel = e.id === selEdgeId;
return (
<g key={e.id} style={{ pointerEvents: 'auto' }}
onClick={(ev) => { ev.stopPropagation(); setSelection({ type: 'edge', id: e.id }); }}
onContextMenu={onEdgeContextMenu?.(e.id)}>
<path d={d} className={`maze-edge ${klass} ${animateEdges ? 'maze-edge-dash' : ''}`} markerEnd={`url(#${marker})`}
style={{ strokeWidth: isSel ? 2.5 : 1.5 }} />
<path d={d} stroke="transparent" strokeWidth="12" fill="none" style={{ cursor: 'pointer' }} />
{e.label && (
<text x={cx} y={(y1 + y2) / 2 - 6} textAnchor="middle"
fill={e.traffic === 'hot' ? '#ff4141' : '#ee82ee'}
fontSize="9" fontFamily="var(--font-mono)" letterSpacing="1">
{e.label}
</text>
)}
</g>
);
})}
{edgeDraw && (() => {
const cx = (edgeDraw.fromX + edgeDraw.toX) / 2;
const d = `M${edgeDraw.fromX},${edgeDraw.fromY} C${cx},${edgeDraw.fromY} ${cx},${edgeDraw.toY} ${edgeDraw.toX},${edgeDraw.toY}`;
return <path d={d} className={`ghost-edge ${edgeDraw.hoverTarget ? 'snap' : ''}`} />;
})()}
</svg>
<div className="maze-nodes">
{nets.map((net) => {
const inactive = net.kind !== 'internet' && !activeNetIds.has(net.id);
return (
<NetBox
key={net.id}
net={net}
selected={net.id === selNetId}
dropTarget={dropTargetId === net.id}
inactive={inactive}
deployed={deployed}
onSelect={selectNet}
onHeaderMouseDown={onNetMouseDown}
onResizeMouseDown={onNetResizeMouseDown}
onContextMenu={onNetContextMenu?.(net.id)}
/>
);
})}
{nodes.map((n) => {
const p = absPos(n);
return (
<NodeCard
key={n.id}
node={n}
absX={p.x}
absY={p.y}
selected={n.id === selNodeId}
deployed={deployed}
dragging={dragging && n.id === selNodeId}
selectedServiceSlug={n.id === selServiceNodeId ? selServiceSlug : null}
onSelect={selectNode}
onSelectService={onSelectService}
onMouseDown={onNodeMouseDown}
onPortMouseDown={onPortMouseDown}
onContextMenu={onNodeContextMenu}
/>
);
})}
</div>
</div>
{(onResetView || onAutoLayout || onZoomIn || onZoomOut) && (
<div className="maze-toolbar">
{onResetView && (
<button type="button" className="maze-btn ghost small" onClick={onResetView} title="Reset pan + zoom">
<RotateCcw size={11} /> RESET VIEW
</button>
)}
{onAutoLayout && (
<button type="button" className="maze-btn ghost small" onClick={onAutoLayout} title="Auto-layout nodes">
<LayoutGrid size={11} /> AUTO-LAYOUT
</button>
)}
{onZoomOut && (
<button type="button" className="maze-btn ghost small" onClick={onZoomOut} title="Zoom out">
<ZoomOut size={11} />
</button>
)}
{onZoomIn && (
<button type="button" className="maze-btn ghost small" onClick={onZoomIn} title="Zoom in">
<ZoomIn size={11} />
</button>
)}
</div>
)}
<div className="maze-status">
<span className={`status-seg ${sseConnected ? 'live' : 'dim'}`}>
<span className={`status-dot ${sseConnected ? 'active' : 'idle'}`} />
GRAPH {sseConnected ? 'LIVE' : 'IDLE'}
</span>
<span className="status-seg">PAN: {Math.round(pan.x)},{Math.round(pan.y)}</span>
<span className="status-seg">ZOOM: {Math.round(zoom * 100)}%</span>
<span className="status-seg">AS-OF {lastEventAt ? fmtTime(lastEventAt) : '--:--:--'}</span>
{!animateEdges && (
<span className="status-seg" title={`Flow animation auto-disabled above ${ANIMATE_EDGE_LIMIT} edges (${edges.length} active) to keep the canvas responsive.`}>
MOTION: OFF
</span>
)}
</div>
<div className="maze-legend">
<div className="lg-row"><span className="lg-swatch alert" /> ACTIVE ATTACK</div>
<div className="lg-row"><span className="lg-swatch violet" /> OBSERVED FLOW</div>
<div className="lg-row"><span className="lg-swatch matrix" /> CONFIGURED</div>
<div className="lg-row"><span className="lg-swatch inactive" /> INACTIVE NET</div>
</div>
</div>
);
});
export default Canvas;

View File

@@ -0,0 +1,98 @@
import React, { useEffect, useRef, useState } from 'react';
import { ChevronRight } from '../../icons';
export interface MenuItem {
label: string;
onClick?: () => void;
disabled?: boolean;
title?: string;
danger?: boolean;
separator?: boolean;
icon?: React.ReactNode;
submenu?: MenuItem[];
}
interface Props {
x: number;
y: number;
items: MenuItem[];
onClose: () => void;
title?: string;
}
const ContextMenu: React.FC<Props> = ({ x, y, items, onClose, title }) => {
const ref = useRef<HTMLDivElement>(null);
const [openSub, setOpenSub] = useState<number | null>(null);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('mousedown', onDown);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousedown', onDown);
window.removeEventListener('keydown', onKey);
};
}, [onClose]);
const renderItem = (it: MenuItem, i: number) => {
if (it.separator) return <div key={i} className="ctx-divider" />;
const hasSub = !!it.submenu?.length;
return (
<div
key={i}
className="ctx-item-wrap"
onMouseEnter={() => setOpenSub(hasSub ? i : null)}
>
<button
type="button"
className={`ctx-item ${it.danger ? 'danger' : ''}`}
disabled={it.disabled}
title={it.title}
onClick={() => {
if (it.disabled) return;
if (hasSub) return;
it.onClick?.();
onClose();
}}
>
{it.icon && <span className="ctx-icon">{it.icon}</span>}
<span className="ctx-label">{it.label}</span>
{hasSub && <ChevronRight size={12} className="ctx-chev" />}
</button>
{hasSub && openSub === i && (
<div className="ctx-submenu">
{it.submenu!.map((s, j) =>
s.separator ? (
<div key={j} className="ctx-divider" />
) : (
<button
key={j}
type="button"
className={`ctx-item ${s.danger ? 'danger' : ''}`}
disabled={s.disabled}
title={s.title}
onClick={() => { if (!s.disabled) { s.onClick?.(); onClose(); } }}
>
{s.icon && <span className="ctx-icon">{s.icon}</span>}
<span className="ctx-label">{s.label}</span>
</button>
),
)}
</div>
)}
</div>
);
};
return (
<div ref={ref} className="ctx-menu" style={{ left: x, top: y }}>
{title && <div className="ctx-title">{title}</div>}
{items.map(renderItem)}
</div>
);
};
export default ContextMenu;

View File

@@ -0,0 +1,303 @@
import React, { useMemo } from 'react';
import {
ArrowLeft, ArrowRight, Crosshair, Globe, GitMerge, MousePointer2, Plus,
Server, Trash2, X, Shield,
} from '../../icons';
import type { Net, MazeNode, Edge } from './types';
import { DEFAULT_SERVICES } from './data';
export type Selection =
| { type: 'net'; id: string }
| { type: 'node'; id: string }
| { type: 'edge'; id: string }
| { type: 'service'; id: string; nodeId: string }
| null;
interface Props {
selection: Selection;
nets: Net[];
nodes: MazeNode[];
edges: Edge[];
topologyStatus?: string;
onClose?: () => void;
onDeleteNet?: (id: string) => void;
onDeleteNode?: (id: string) => void;
onDeleteEdge?: (id: string) => void;
onRemoveService?: (nodeId: string, slug: string) => void;
onAddDecky?: (netId: string) => void;
setSelection?: (sel: Selection) => void;
pendingChanges?: number;
className?: string;
}
const Inspector: React.FC<Props> = ({
selection, nets, nodes, edges, topologyStatus, onClose,
onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService, onAddDecky, setSelection,
pendingChanges = 0,
className = '',
}) => {
const net = selection?.type === 'net' ? nets.find((n) => n.id === selection.id) : undefined;
const node = selection?.type === 'node' ? nodes.find((n) => n.id === selection.id) : undefined;
const edge = selection?.type === 'edge' ? edges.find((e) => e.id === selection.id) : undefined;
const serviceSel = selection?.type === 'service' ? selection : undefined;
const serviceMeta = serviceSel ? DEFAULT_SERVICES.find((s) => s.slug === serviceSel.id) : undefined;
const serviceParent = serviceSel ? nodes.find((n) => n.id === serviceSel.nodeId) : undefined;
const serviceParentNet = serviceParent ? nets.find((n) => n.id === serviceParent.netId) : undefined;
const activeNetIds = useMemo(() => {
const s = new Set<string>();
edges.forEach((e) => {
const f = nodes.find((n) => n.id === e.from);
const t = nodes.find((n) => n.id === e.to);
if (f) s.add(f.netId);
if (t) s.add(t.netId);
});
return s;
}, [edges, nodes]);
const typeLabel = selection ? selection.type.toUpperCase() : 'IDLE';
const isGateway = node?.kind === 'decky' && !!node.decky_config?.forwards_l3;
const isObserved = node?.kind === 'observed';
return (
<aside className={`maze-inspector ${className}`}>
<div className="maze-inspector-title">
<Crosshair size={12} className="violet-accent" />
<span>INSPECTOR</span>
<span className="dim inspector-type-label">{typeLabel}</span>
{onClose && (
<button
type="button"
className="inspector-close-btn"
onClick={onClose}
title="Hide inspector"
>
<X size={12} />
</button>
)}
</div>
<div className="maze-inspector-body">
{!selection && (
<div className="inspector-empty">
<MousePointer2 size={22} style={{ opacity: 0.4, marginBottom: 10 }} />
<div>SELECT A NODE, NETWORK, OR EDGE</div>
<div style={{ marginTop: 10, fontSize: '0.6rem', opacity: 0.5 }}>
Right-click for actions
</div>
</div>
)}
{node && (
<>
<div className="inspector-head">
<span className={`status-dot ${node.status}`} />
<span className="inspector-head-title">{node.name}</span>
<span className="chip violet inspector-head-chip">{node.archetype}</span>
</div>
<div className="kvs">
<div className="k">NETWORK</div>
<div className="v violet-accent">
{nets.find((nn) => nn.id === node.netId)?.label ?? node.netId}
</div>
<div className="k">STATUS</div>
<div className="v">{node.status.toUpperCase()}</div>
<div className="k">SERVICES</div>
<div className="v">
<div className="inspector-service-row">
{node.services.length === 0 && <span className="dim"></span>}
{node.services.map((s) => (
<span key={s} className="service-tag">{s}</span>
))}
</div>
</div>
</div>
<div>
<div className="type-label inspector-section-label">CONNECTIONS</div>
{edges.filter((e) => e.from === node.id || e.to === node.id).map((e) => {
const otherId = e.from === node.id ? e.to : e.from;
const other = nodes.find((n) => n.id === otherId);
const Arrow = e.from === node.id ? ArrowRight : ArrowLeft;
return (
<div key={e.id} className="inspector-conn-row">
<Arrow size={10} className={e.traffic === 'hot' ? 'alert-text' : 'dim'} />
<span>{other?.name ?? '—'}</span>
<span className="chip dim-chip inspector-conn-chip">{e.traffic}</span>
</div>
);
})}
{edges.filter((e) => e.from === node.id || e.to === node.id).length === 0 && (
<div className="dim inspector-empty-line">NO EDGES</div>
)}
</div>
{onDeleteNode && (
<button
type="button"
className="maze-btn alert small"
disabled={isObserved || isGateway}
title={
isObserved ? 'observed entity — not a deployed decky'
: isGateway ? 'DMZ gateway — pinned to its DMZ network'
: undefined
}
onClick={() => !isObserved && !isGateway && onDeleteNode(node.id)}
>
<Trash2 size={12} /> REMOVE FROM GRAPH
</button>
)}
</>
)}
{net && (
<>
<div className="inspector-head">
{net.kind === 'internet'
? <Globe size={14} className="violet-accent" />
: <GitMerge size={14} className="violet-accent" />}
<span className="inspector-head-title">{net.label}</span>
{net.kind !== 'internet' && !activeNetIds.has(net.id) && (
<span className="chip-mini inspector-head-chip">INACTIVE</span>
)}
</div>
<div className="kvs">
<div className="k">KIND</div><div className="v">{net.kind.toUpperCase()}</div>
<div className="k">CIDR</div><div className="v">{net.cidr}</div>
<div className="k">DECKIES</div>
<div className="v" style={{ fontWeight: 700 }}>
{nodes.filter((n) => n.netId === net.id).length}
</div>
</div>
<div>
<div className="type-label inspector-section-label">MEMBERS</div>
{nodes.filter((n) => n.netId === net.id).map((n) => (
<div
key={n.id}
className="inspector-member-row"
onClick={() => setSelection?.({ type: 'node', id: n.id })}
>
<span className={`status-dot ${n.status}`} />
<span>{n.name}</span>
<span className="dim inspector-member-arch">{n.archetype}</span>
</div>
))}
{nodes.filter((n) => n.netId === net.id).length === 0 && (
<div className="dim inspector-empty-line">NO MEMBERS</div>
)}
</div>
{net.kind !== 'internet' && onAddDecky && (
<button type="button" className="maze-btn small" onClick={() => onAddDecky(net.id)}>
<Plus size={10} /> ADD DECKY
</button>
)}
{net.kind !== 'internet' && onDeleteNet && (
<button
type="button"
className="maze-btn alert small"
onClick={() => onDeleteNet(net.id)}
>
<Trash2 size={10} /> REMOVE NETWORK
</button>
)}
</>
)}
{edge && (
<>
<div className="inspector-head">
<Server size={14} className="violet-accent" />
<span className="inspector-head-title">EDGE · {edge.id.slice(0, 8)}</span>
</div>
<div className="kvs">
<div className="k">FROM</div>
<div className="v">{nodes.find((n) => n.id === edge.from)?.name ?? edge.from}</div>
<div className="k">TO</div>
<div className="v">{nodes.find((n) => n.id === edge.to)?.name ?? edge.to}</div>
<div className="k">TRAFFIC</div>
<div className="v">{edge.traffic.toUpperCase()}</div>
{edge.label && (
<>
<div className="k">LABEL</div>
<div className="v">{edge.label}</div>
</>
)}
</div>
{onDeleteEdge && (
<button
type="button"
className="maze-btn alert small"
onClick={() => onDeleteEdge(edge.id)}
>
<Trash2 size={10} /> CUT EDGE
</button>
)}
</>
)}
{serviceSel && (
<>
<div className="inspector-head">
<Shield
size={14}
className={serviceMeta?.risk === 'high' ? 'alert-text' : 'violet-accent'}
/>
<span className="inspector-head-title">
{serviceMeta?.name ?? serviceSel.id.toUpperCase()}
</span>
{serviceMeta && (
<span className={`chip inspector-head-chip ${
serviceMeta.risk === 'high' ? 'alert'
: serviceMeta.risk === 'med' ? 'violet'
: 'dim-chip'
}`}>
{serviceMeta.risk.toUpperCase()}
</span>
)}
</div>
<div className="kvs">
<div className="k">EXPOSED ON</div>
<div className="v violet-accent">{serviceParent?.name ?? '—'}</div>
<div className="k">PROTOCOL</div>
<div className="v">{(serviceMeta?.proto ?? '—').toUpperCase()}</div>
<div className="k">PORT</div>
<div className="v" style={{ fontWeight: 700 }}>{serviceMeta?.port ?? '—'}</div>
<div className="k">SUBNET</div>
<div className="v">{serviceParentNet?.label ?? '—'}</div>
</div>
{onRemoveService && serviceParent && serviceParent.kind !== 'observed' && (
<button
type="button"
className="maze-btn alert small"
disabled={topologyStatus === 'degraded'}
title={topologyStatus === 'degraded' ? 'topology degraded — mutations blocked' : undefined}
onClick={() => onRemoveService(serviceSel.nodeId, serviceSel.id)}
>
<Trash2 size={10} /> REMOVE SERVICE
</button>
)}
</>
)}
{pendingChanges > 0 && (
<div className="inspector-diff-block">
<div className="type-label inspector-section-label">PENDING DIFF</div>
<div className="maze-diff">
<span className="ctx"> +{pendingChanges} graph mutation(s)</span>{'\n'}
<span className="ctx"> networks: {nets.length}</span>{'\n'}
<span className="ctx"> deckies: {nodes.length}</span>{'\n'}
<span className="ctx"> paths: {edges.length}</span>
</div>
</div>
)}
{topologyStatus && !selection && (
<div className="kvs inspector-status-block">
<div className="k">TOPOLOGY</div>
<div className="v">{topologyStatus.toUpperCase()}</div>
</div>
)}
</div>
</aside>
);
};
export default Inspector;

View File

@@ -0,0 +1,590 @@
/* ── MazeNET canvas ─────────────────────────── */
body.maze-fullscreen .sidebar,
body.maze-fullscreen .topbar {
display: none !important;
}
body.maze-fullscreen .content-viewport {
padding: 16px 32px;
}
body.maze-fullscreen .maze-shell {
/* Full viewport minus content-viewport padding (16 top + 32 bottom) and header+gap. */
/* With flex:1 this stays correct because maze-page fills 100% of the new viewport. */
}
.maze-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
}
.maze-page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
gap: 24px;
}
.maze-page-header h1 { font-size: 1.3rem; letter-spacing: 4px; font-weight: 700; }
.maze-page-sub { font-size: 0.7rem; opacity: 0.5; letter-spacing: 1px; }
.maze-page-actions { display: flex; gap: 10px; align-items: center; }
.maze-btn {
cursor: pointer;
background: transparent;
border: 1px solid var(--matrix);
color: var(--matrix);
padding: 7px 14px;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.3s;
}
.maze-btn:hover { background: var(--matrix); color: #000; box-shadow: var(--matrix-glow); }
.maze-btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; }
.maze-btn.ghost:hover {
background: transparent; color: var(--matrix); opacity: 1;
border-color: var(--matrix); box-shadow: var(--matrix-glow);
}
.maze-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.maze-btn:disabled:hover { background: transparent; color: var(--matrix); box-shadow: none; }
.maze-shell {
display: grid;
grid-template-columns: 240px 1fr 320px;
gap: 0;
flex: 1;
min-height: 0;
margin: 0 -32px -32px;
border-top: 1px solid var(--border);
transition: grid-template-columns 260ms cubic-bezier(0.4, 0, 0.2, 1);
}
.maze-palette,
.maze-inspector {
transition: opacity 200ms ease, transform 260ms cubic-bezier(0.4, 0, 0.2, 1);
min-width: 0;
}
.maze-palette.collapsed,
.maze-inspector.collapsed {
opacity: 0;
pointer-events: none;
overflow: hidden;
}
.maze-palette.collapsed { transform: translateX(-8px); }
.maze-inspector.collapsed { transform: translateX(8px); }
/* ── Palette ────────────────────────────────── */
.maze-palette {
background: var(--panel);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 18px;
}
.palette-group { display: flex; flex-direction: column; gap: 6px; }
.palette-group > label {
font-size: 0.6rem; letter-spacing: 1.5px; opacity: 0.5; text-transform: uppercase;
}
.palette-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border: 1px solid var(--border);
cursor: grab; transition: all 0.15s; font-size: 0.72rem;
color: var(--matrix); background: transparent;
}
.palette-item:hover {
border-color: var(--violet); color: var(--violet); background: var(--violet-tint-10);
}
.palette-item:active { cursor: grabbing; }
.palette-item .chip-mini {
font-size: 0.68rem; padding: 2px 6px;
border: 1px solid var(--accent-tint-30);
background: var(--accent-tint-10);
color: var(--accent);
letter-spacing: 0.5px; margin-left: auto;
font-family: var(--font-mono); opacity: 1;
}
.palette-hint {
font-size: 0.62rem; opacity: 0.5; line-height: 1.6; letter-spacing: 0.5px;
}
.palette-subgroup { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; }
.palette-subgroup:first-child { margin-top: 0; }
.palette-subgroup-label {
font-size: 0.55rem; letter-spacing: 1.5px; opacity: 0.4;
color: var(--violet); margin-top: 6px;
}
.violet-accent { color: var(--violet); }
.alert-text { color: var(--alert); }
.matrix-text { color: var(--matrix); }
/* ── Canvas ─────────────────────────────────── */
.maze-canvas-wrap {
position: relative; background: #000;
overflow: hidden; user-select: none;
height: 100%; min-height: 0;
}
.maze-pan-layer { position: absolute; inset: 0; will-change: transform; }
.maze-grid-bg {
position: absolute; inset: 0;
pointer-events: none; opacity: 0.6; overflow: hidden; background: #000;
}
.maze-grid-bg svg { display: block; width: 100%; height: 100%; }
.maze-svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.maze-nodes { position: absolute; inset: 0; }
.maze-empty-hint {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
pointer-events: none; font-size: 0.72rem; letter-spacing: 1.5px;
color: var(--fg-4); text-transform: uppercase;
}
/* ── Network box ────────────────────────────── */
.maze-net-box {
position: absolute;
border: 1px dashed var(--border);
background: rgba(13, 17, 23, 0.6);
padding: 32px 16px 16px;
min-width: 220px; min-height: 140px;
transition: border-color 0.2s;
cursor: move;
}
.maze-net-box.selected {
border-color: var(--violet);
box-shadow: 0 0 0 1px var(--violet-tint-10) inset;
}
.maze-net-box.drop-target {
border-color: var(--matrix); background: var(--matrix-tint-5);
}
.maze-net-box.internet {
border-color: var(--alert); background: rgba(255, 65, 65, 0.04);
}
.maze-net-box.dmz {
border-color: var(--alert); background: rgba(255, 65, 65, 0.06);
border-style: dashed;
}
.maze-net-box.dmz .maze-net-box-head {
color: var(--alert); border-bottom-color: rgba(255, 65, 65, 0.45);
}
/* Deployed: topology is active/degraded — make it visually unmistakable.
* Subnet LANs glow matrix-green; DMZ stays hot red (and gets a stronger
* glow so you can tell it's live). */
.maze-net-box.deployed {
border-style: solid;
border-color: var(--matrix);
background: rgba(0, 255, 65, 0.05);
box-shadow: 0 0 0 1px rgba(0, 255, 65, 0.25) inset, var(--matrix-glow);
}
.maze-net-box.deployed .maze-net-box-head {
color: var(--matrix); border-bottom-color: rgba(0, 255, 65, 0.45);
}
.maze-net-box.deployed.dmz {
border-color: var(--alert);
background: rgba(255, 65, 65, 0.09);
box-shadow: 0 0 0 1px rgba(255, 65, 65, 0.35) inset,
0 0 16px rgba(255, 65, 65, 0.5);
}
.maze-net-box.deployed.dmz .maze-net-box-head {
color: var(--alert); border-bottom-color: rgba(255, 65, 65, 0.55);
}
.maze-net-box.inactive {
opacity: 0.42; filter: grayscale(0.7); border-style: dotted;
}
.maze-net-box.inactive .maze-net-box-head { color: rgba(255, 255, 255, 0.5); }
/* Optimistic placeholder for an enqueued LAN-add. Amber tint matches
* the REAP button voice — clearly "in-flight, not committed" without
* collapsing into the dimmed-out 'inactive' style which means the
* opposite (no traffic on a deployed LAN). */
.maze-net-box.pending {
border-color: var(--warn, #e0a040);
background: rgba(224, 160, 64, 0.04);
border-style: dashed;
filter: none; opacity: 1;
}
.maze-net-box.pending .maze-net-box-head {
color: var(--warn, #e0a040);
border-bottom-color: rgba(224, 160, 64, 0.45);
}
.maze-net-box.pending .cidr { color: rgba(224, 160, 64, 0.7); }
.maze-net-box-head {
position: absolute; top: 0; left: 0; right: 0;
padding: 6px 12px; border-bottom: 1px dashed var(--border);
display: flex; align-items: center; gap: 8px; justify-content: space-between;
font-size: 0.65rem; letter-spacing: 1.5px; opacity: 0.8;
background: rgba(13, 17, 23, 0.8); cursor: move;
}
.maze-net-box-head .cidr { opacity: 0.5; font-size: 0.6rem; letter-spacing: 1px; }
.maze-net-box.internet .maze-net-box-head {
color: var(--alert); border-bottom-color: rgba(255, 65, 65, 0.4);
}
/* ── Network resize handles ─────────────────── */
.net-resize { position: absolute; z-index: 2; }
.net-resize-e { top: 8px; bottom: 8px; right: -4px; width: 8px; cursor: ew-resize; }
.net-resize-w { top: 8px; bottom: 8px; left: -4px; width: 8px; cursor: ew-resize; }
.net-resize-s { left: 8px; right: 8px; bottom: -4px; height: 8px; cursor: ns-resize; }
.net-resize-n { left: 8px; right: 8px; top: -4px; height: 8px; cursor: ns-resize; }
.net-resize-se { right: -5px; bottom: -5px; width: 12px; height: 12px; cursor: nwse-resize; }
.net-resize-sw { left: -5px; bottom: -5px; width: 12px; height: 12px; cursor: nesw-resize; }
.net-resize-ne { right: -5px; top: -5px; width: 12px; height: 12px; cursor: nesw-resize; }
.net-resize-nw { left: -5px; top: -5px; width: 12px; height: 12px; cursor: nwse-resize; }
.maze-net-box.selected .net-resize-se,
.maze-net-box.selected .net-resize-sw,
.maze-net-box.selected .net-resize-ne,
.maze-net-box.selected .net-resize-nw { background: var(--violet); opacity: 0.5; }
.maze-net-box:hover .net-resize-se,
.maze-net-box:hover .net-resize-sw,
.maze-net-box:hover .net-resize-ne,
.maze-net-box:hover .net-resize-nw { background: var(--border); opacity: 0.8; }
/* ── Decky/observed node card ──────────────── */
.maze-node {
position: absolute; width: 140px;
background: var(--panel); border: 1px solid var(--border);
padding: 8px 10px; cursor: grab;
transition: border-color 0.15s, box-shadow 0.15s;
user-select: none; display: flex; flex-direction: column; gap: 4px;
}
.maze-node:hover { border-color: var(--matrix); box-shadow: var(--matrix-glow); z-index: 3; }
.maze-node.selected { border-color: var(--violet); box-shadow: var(--violet-glow); z-index: 4; }
.maze-node.hot { border-color: var(--alert); }
.maze-node.hot::after {
content: ''; position: absolute; inset: -1px;
border: 1px solid var(--alert); opacity: 0.4;
pointer-events: none; animation: decnet-pulse 1s infinite alternate;
}
.maze-node.observed { border-style: dashed; }
.maze-node.dragging { opacity: 0.8; z-index: 10; cursor: grabbing; }
.maze-node.deployed {
border-color: var(--matrix);
box-shadow: var(--matrix-glow);
background: rgba(0, 255, 65, 0.04);
}
.maze-node.deployed .mn-head { color: var(--matrix); }
.maze-node.deployed.dmz-gateway {
border-color: var(--alert);
box-shadow: 0 0 12px rgba(255, 65, 65, 0.55);
background: rgba(255, 65, 65, 0.06);
}
.maze-node.deployed.dmz-gateway .mn-head { color: var(--alert); }
.maze-node .mn-head {
display: flex; align-items: center; gap: 6px;
font-size: 0.74rem; font-weight: 700; letter-spacing: 0.5px;
}
.maze-node .mn-sub { font-size: 0.62rem; opacity: 0.6; letter-spacing: 1px; }
.maze-node .mn-services { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 2px; }
.maze-node .mn-services .service-tag {
font-size: 0.55rem; padding: 1px 5px; letter-spacing: 0.5px;
cursor: pointer; transition: all 0.12s;
border: 1px solid var(--violet); color: var(--violet); border-radius: 2px;
white-space: nowrap;
}
.maze-node .mn-services .service-tag:hover { background: var(--violet-tint-10); }
.maze-node .mn-services .service-tag.hot { border-color: var(--alert); color: var(--alert); }
.maze-node .mn-services .service-tag.service-selected {
background: var(--violet); color: #000; font-weight: 700;
}
.maze-node .mn-port {
position: absolute; width: 8px; height: 8px;
background: var(--panel); border: 1px solid var(--matrix);
top: 50%; transform: translateY(-50%);
}
.maze-node .mn-port.in { left: -5px; }
.maze-node .mn-port.out { right: -5px; cursor: crosshair; }
.maze-node .mn-port:hover { background: var(--matrix); box-shadow: var(--matrix-glow); }
/* ── Edges ──────────────────────────────────── */
.maze-edge { stroke: var(--matrix); stroke-width: 1.5; fill: none; opacity: 0.5; }
.maze-edge.active { opacity: 0.9; stroke: var(--violet); }
.maze-edge.hot { stroke: var(--alert); opacity: 0.9; }
.maze-edge-dash { stroke-dasharray: 4 3; animation: dash-flow 0.6s linear infinite; }
@keyframes dash-flow { to { stroke-dashoffset: -14; } }
.ghost-edge {
stroke: var(--violet); stroke-width: 1.5;
stroke-dasharray: 3 3; opacity: 0.7; fill: none;
}
/* ── Canvas overlays ────────────────────────── */
.maze-toolbar { position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; z-index: 5; }
.maze-status {
position: absolute; bottom: 12px; left: 12px;
display: flex; gap: 12px; z-index: 5;
font-size: 0.62rem; opacity: 0.6; letter-spacing: 1px;
background: rgba(0, 0, 0, 0.6); padding: 6px 10px; border: 1px solid var(--border);
}
.maze-legend {
position: absolute; bottom: 12px; right: 12px; z-index: 5;
background: rgba(13, 17, 23, 0.85); border: 1px solid var(--border);
padding: 8px 10px; font-size: 0.6rem; letter-spacing: 1px;
display: flex; flex-direction: column; gap: 4px;
}
.maze-legend .lg-row { display: flex; align-items: center; gap: 6px; }
.maze-legend .lg-swatch { width: 14px; height: 2px; background: var(--matrix); }
.maze-legend .lg-swatch.alert { background: var(--alert); box-shadow: 0 0 6px var(--alert); }
.maze-legend .lg-swatch.violet { background: var(--violet); box-shadow: 0 0 6px var(--violet); }
.maze-legend .lg-swatch.matrix { background: var(--matrix); }
.maze-legend .lg-swatch.inactive {
background: transparent;
height: 0;
border-top: 1px dashed var(--border);
}
/* Status bar segments */
.maze-status .status-seg { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.maze-status .status-seg.live { color: var(--matrix); opacity: 0.9; }
.maze-status .status-seg.dim { opacity: 0.45; }
/* Toolbar button sizing override */
.maze-toolbar .maze-btn.small {
padding: 4px 10px; font-size: 0.62rem; letter-spacing: 1.5px;
background: rgba(0, 0, 0, 0.6);
}
/* NodeCard head icon alignment */
.maze-node .mn-head .mn-head-icon { opacity: 0.8; flex-shrink: 0; }
.maze-node .mn-head .mn-head-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── Inspector ──────────────────────────────── */
.maze-inspector {
background: var(--panel); border-left: 1px solid var(--border);
overflow-y: auto; padding: 0;
display: flex; flex-direction: column;
}
.maze-inspector-title {
display: flex; align-items: center; gap: 8px;
padding: 12px 14px; border-bottom: 1px solid var(--border);
font-size: 0.72rem; letter-spacing: 2px; font-weight: 700;
position: sticky; top: 0; background: var(--panel); z-index: 1; flex-shrink: 0;
}
.maze-inspector-body {
padding: 14px; display: flex; flex-direction: column; gap: 14px;
}
.inspector-empty {
opacity: 0.5; text-align: center;
padding: 30px 10px; font-size: 0.7rem; letter-spacing: 1px;
}
.maze-diff {
background: #000; border: 1px solid var(--border);
padding: 10px 12px; font-size: 0.68rem; line-height: 1.6;
white-space: pre; overflow-x: auto;
}
.maze-diff .add { color: var(--matrix); }
.maze-diff .rem { color: var(--alert); }
.maze-diff .ctx { opacity: 0.5; }
.kvs {
display: grid; grid-template-columns: 140px 1fr;
gap: 8px 14px; font-size: 0.78rem;
}
.kvs .k {
opacity: 0.5; letter-spacing: 1px; font-size: 0.65rem;
text-transform: uppercase; align-self: center;
}
.kvs .v { color: var(--matrix); word-break: break-all; }
/* ── Context menu ───────────────────────────── */
.ctx-scrim { position: absolute; inset: 0; z-index: 30; }
.ctx-menu {
position: fixed; z-index: 1000;
width: auto; border-radius: var(--radius-0, 0);
background: var(--panel); border: 1px solid var(--violet);
min-width: 200px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.8), var(--violet-glow);
padding: 4px 0; font-family: var(--font-mono);
}
.ctx-header {
font-size: 0.58rem; letter-spacing: 2px; opacity: 0.5;
padding: 6px 12px 4px; border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.ctx-title {
font-size: 0.58rem; letter-spacing: 2px; opacity: 0.5;
padding: 6px 12px 4px; border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.ctx-item-wrap { position: relative; }
.ctx-icon { display: inline-flex; width: 14px; align-items: center; justify-content: center; opacity: 0.8; }
.ctx-label { flex: 1; }
.ctx-chev { opacity: 0.6; }
.ctx-item {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 7px 12px; font-size: 0.74rem; cursor: pointer; letter-spacing: 0.5px;
background: transparent; border: 0; color: var(--matrix); text-align: left;
font-family: inherit;
}
.ctx-item:disabled { opacity: 0.35; cursor: not-allowed; }
.ctx-item:disabled:hover { background: transparent; color: inherit; }
.ghost-edge.snap { stroke: var(--matrix); opacity: 0.9; }
.ctx-item:hover { background: var(--violet-tint-10); color: var(--violet); }
.ctx-item.danger { color: var(--alert); }
.ctx-item.danger:hover { background: rgba(255, 65, 65, 0.12); }
.ctx-item.disabled { opacity: 0.35; cursor: not-allowed; }
.ctx-item.disabled:hover { background: transparent; color: inherit; }
.ctx-divider { height: 1px; background: var(--border); margin: 4px 0; }
.palette-ghost {
position: fixed; z-index: 2000; pointer-events: none;
padding: 4px 10px; font-family: var(--font-mono); font-size: 0.68rem;
letter-spacing: 1.5px; background: var(--panel);
border: 1px solid var(--violet); color: var(--violet);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6), var(--violet-glow);
}
.ctx-submenu {
position: absolute; left: 100%; top: 0;
background: var(--panel); border: 1px solid var(--violet);
min-width: 180px; padding: 4px 0;
max-height: 320px; overflow-y: auto;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.8);
}
/* ── Inspector: rich visual layout ──────────── */
.inspector-type-label {
margin-left: auto;
font-size: 0.6rem;
letter-spacing: 1px;
}
.inspector-close-btn {
background: transparent;
border: 1px solid var(--border);
color: rgba(255, 255, 255, 0.5);
padding: 3px 5px;
cursor: pointer;
display: flex;
transition: all 0.15s;
}
.inspector-close-btn:hover { color: var(--alert); border-color: var(--alert); }
.inspector-head {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.inspector-head-title { font-size: 0.9rem; font-weight: 700; }
.inspector-head-chip { margin-left: auto; }
.inspector-section-label { margin-bottom: 6px; }
.type-label {
font-size: 0.6rem;
letter-spacing: 1.5px;
opacity: 0.6;
text-transform: uppercase;
}
.inspector-conn-row {
font-size: 0.7rem;
padding: 6px 0;
border-bottom: 1px dashed var(--border);
display: flex;
gap: 6px;
align-items: center;
}
.inspector-conn-chip { margin-left: auto; }
.inspector-member-row {
font-size: 0.72rem;
padding: 5px 0;
display: flex;
gap: 6px;
align-items: center;
cursor: pointer;
}
.inspector-member-row:hover { color: var(--violet); }
.inspector-member-arch { margin-left: auto; font-size: 0.6rem; }
.inspector-empty-line {
font-size: 0.68rem;
padding: 4px 0;
}
.inspector-diff-block {
border-top: 1px solid var(--border);
padding-top: 12px;
}
.inspector-status-block { margin-top: 12px; }
.dim { opacity: 0.5; }
/* Status dots used in inspector head + member rows */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0;
flex-shrink: 0;
}
.status-dot.active { background: var(--matrix); box-shadow: 0 0 8px var(--matrix); }
.status-dot.idle { background: #30363d; }
.status-dot.hot { background: var(--alert); box-shadow: 0 0 8px var(--alert);
animation: decnet-pulse 1s infinite alternate; }
.status-dot.mutating { background: var(--violet); animation: decnet-blink 1s infinite; }
/* Chips — inline badges for archetypes, traffic, severity */
.chip {
font-size: 0.65rem;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--matrix);
color: var(--matrix);
background: var(--matrix-tint-10);
letter-spacing: 1px;
white-space: nowrap;
}
.chip.violet { border-color: var(--violet); color: var(--violet); background: var(--violet-tint-10); }
.chip.matrix { border-color: var(--matrix); color: var(--matrix); background: var(--matrix-tint-10); }
.chip.alert { border-color: var(--alert); color: var(--alert); background: var(--alert-tint-10); }
.chip.dim-chip { border-color: var(--border); color: rgba(0, 255, 65, 0.6); background: transparent; }
.chip-mini {
font-size: 0.55rem;
padding: 1px 5px;
border: 1px solid var(--border);
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.55);
}
/* Inspector buttons */
.maze-btn.small { padding: 5px 10px; font-size: 0.68rem; }
.maze-btn.alert {
border-color: var(--alert);
color: var(--alert);
opacity: 0.85;
}
.maze-btn.alert:hover {
background: var(--alert);
color: #000;
box-shadow: 0 0 10px rgba(255, 65, 65, 0.5);
opacity: 1;
}
/* Service tag reuse for inspector "SERVICES" chip row */
.inspector-service-row {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.maze-inspector .service-tag {
font-size: 0.6rem;
padding: 2px 6px;
letter-spacing: 0.5px;
border: 1px solid var(--violet);
color: var(--violet);
border-radius: 2px;
}
.dragging-from-palette {
position: fixed; pointer-events: none; z-index: 200;
background: var(--panel); border: 1px solid var(--violet);
padding: 6px 10px; font-size: 0.7rem; color: var(--violet);
box-shadow: var(--violet-glow);
}

View File

@@ -0,0 +1,816 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
PanelRightOpen, PanelRightClose, PanelLeftOpen, PanelLeftClose,
Maximize2, Minimize2, RotateCcw, UploadCloud, ArrowLeft,
Plus, Trash2, Zap, Copy, Eye, ShieldAlert, GitMerge, Server, Mail,
} from '../../icons';
import './MazeNET.css';
import axios from '../../utils/api';
import { useSwarmHosts } from '../../hooks/useSwarmHosts';
import Palette from './Palette';
import Canvas from './Canvas';
import Inspector from './Inspector';
import type { Selection } from './Inspector';
import ContextMenu, { type MenuItem } from './ContextMenu';
import { DEFAULT_SERVICES } from './data';
import type { Archetype, ServiceDef } from './data';
import type { Net, MazeNode, Edge, DeckyNode } from './types';
import { useMazeApi } from './useMazeApi';
import { useTopologyEditor } from './useTopologyEditor';
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
import { useLayoutPersistor } from './useMazeLayoutStore';
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
import { useToast } from '../Toasts/useToast';
/* Short unique suffix for default names — avoids the DB uniqueness
* constraint regardless of delete/re-add sequencing on the client. */
const hex4 = (): string => {
const r = typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID().replace(/-/g, '')
: Math.random().toString(16).slice(2);
return r.slice(0, 4);
};
const MazeNET: React.FC = () => {
const api = useMazeApi();
const navigate = useNavigate();
const { push: pushToast } = useToast();
const [params] = useSearchParams();
const topologyId = params.get('topology') ?? '';
const { byUuid: hostsByUuid } = useSwarmHosts();
const [nets, setNets] = useState<Net[]>([]);
const [nodes, setNodes] = useState<MazeNode[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [topoStatus, setTopoStatus] = useState<string>('pending');
const [topoName, setTopoName] = useState<string>('');
const [topoVersion, setTopoVersion] = useState<number>(0);
const [topoTargetHost, setTopoTargetHost] = useState<string | null>(null);
const [topoMode, setTopoMode] = useState<string>('unihost');
const [selection, setSelection] = useState<Selection>(null);
const [inspectorOpen, setInspectorOpen] = useState(true);
const [paletteOpen, setPaletteOpen] = useState(true);
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
const cls = 'maze-fullscreen';
if (fullscreen) document.body.classList.add(cls);
else document.body.classList.remove(cls);
return () => document.body.classList.remove(cls);
}, [fullscreen]);
// Request/exit browser fullscreen alongside the in-app chrome hide.
// Ignore failures (fullscreen requires a user gesture; the chrome-only
// mode still works if the API rejects).
useEffect(() => {
if (fullscreen && !document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {});
} else if (!fullscreen && document.fullscreenElement) {
document.exitFullscreen?.().catch(() => {});
}
}, [fullscreen]);
// Sync state if the user presses F11/Esc to leave fullscreen from
// outside our button.
useEffect(() => {
const onFsChange = () => {
if (!document.fullscreenElement) setFullscreen(false);
};
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && fullscreen) setFullscreen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [fullscreen]);
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
const [archetypes, setArchetypes] = useState<Archetype[]>(DEFAULT_ARCHETYPES);
useLayoutPersistor(topologyId || null, nets, nodes);
const [loadErr, setLoadErr] = useState<string | null>(null);
const [actionErr, setActionErr] = useState<string | null>(null);
const [deploying, setDeploying] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
const editor = useTopologyEditor({ api, topoStatus, topoVersion });
const flashErr = useCallback((err: unknown, fallback: string) => {
const msg = (err as { response?: { data?: { detail?: string } }; message?: string })
?.response?.data?.detail ?? (err as Error)?.message ?? fallback;
setActionErr(msg);
setTimeout(() => setActionErr(null), 4000);
}, []);
/* ── Palette drop — create LANs / deckies / services via REST ─── */
const onPaletteDrop = useCallback(
async (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => {
if (!topologyId) return;
if (drag.kind === 'network-subnet' || drag.kind === 'network-dmz') {
const isDmz = drag.kind === 'network-dmz';
if (isDmz && nets.some((n) => n.kind === 'dmz')) {
flashErr(null, 'topology already has a DMZ');
return;
}
// Append to the 3-col grid matching adaptTopology. Counting
// existing nets PLUS any pending placeholders (live-topology
// enqueued mutations that haven't echoed through SSE yet)
// keeps successive drops from stacking on the same cell.
const w = 300, h = 240;
const GAP = 40, COLS = 3;
const i = nets.filter((n) => n.kind !== 'internet').length;
const x = GAP + (i % COLS) * (w + GAP);
const y = GAP + Math.floor(i / COLS) * (h + GAP);
const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`;
try {
const subnet = await api.getNextSubnet().catch(() => undefined);
const lanRes = await editor.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) });
if (lanRes.kind !== 'applied') {
// Live topology: mutator will materialise the LAN. Drop
// a placeholder net so the grid index advances and the
// user gets an immediate visual ack. Real LAN arriving
// via SSE replaces the placeholder by id when its
// canonical id lands; until then, the temp id is unique.
const tempId = `pending-lan-${name}`;
setNets((p) => [...p, {
id: tempId, name, label: name.toUpperCase(),
cidr: subnet ?? '', kind: isDmz ? 'dmz' : 'subnet',
x, y, w, h, pending: true,
}]);
return;
}
const lan = lanRes.data;
const net: Net = {
id: lan.id, name: lan.name, label: lan.name.toUpperCase(), cidr: lan.subnet,
kind: isDmz ? 'dmz' : 'subnet', x, y, w, h,
};
setNets((p) => [...p, net]);
if (isDmz) {
const gwName = `dmz-gateway-${hex4()}`;
const gwRes = await editor.addDeckyToLan(
topologyId,
{ name: gwName, services: ['ssh'], x: 20, y: 40,
decky_config: { archetype: 'deaddeck', forwards_l3: true } },
lan.id, lan.name,
{ is_bridge: true, forwards_l3: true },
);
if (gwRes.kind !== 'applied') return;
const gw = gwRes.data;
const gwNode: DeckyNode = {
kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name,
archetype: 'deaddeck', services: ['ssh'], status: 'idle',
x: 20, y: 40, decky_config: { forwards_l3: true },
};
setNodes((p) => [...p, gwNode]);
}
} catch (err) {
flashErr(err, 'create network failed');
}
return;
}
if (drag.kind === 'archetype') {
if (!overNetId) return;
const net = nets.find((n) => n.id === overNetId);
if (!net) return;
const arch = archetypes.find((a) => a.slug === drag.slug);
const archSlug = drag.slug;
const dServices = drag.services ?? arch?.services ?? [];
const nx = Math.max(8, Math.round(world.x - net.x - 70));
const ny = Math.max(28, Math.round(world.y - net.y - 24));
const name = `decky-${hex4()}`;
try {
const dRes = await editor.addDeckyToLan(
topologyId,
{ name, services: dServices, x: nx, y: ny,
decky_config: { archetype: archSlug } },
overNetId, net.name,
);
if (dRes.kind !== 'applied') return;
const decky = dRes.data;
const node: DeckyNode = {
kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name,
archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny,
};
setNodes((p) => [...p, node]);
} catch (err) {
flashErr(err, 'create decky failed');
}
return;
}
if (drag.kind === 'service') {
if (!overNodeId) return;
const target = nodes.find((n) => n.id === overNodeId);
if (!target || target.kind !== 'decky') return;
if (target.services.includes(drag.slug)) return;
const nextServices = [...target.services, drag.slug];
try {
const r = await editor.updateDecky(topologyId, overNodeId, target.name, { services: nextServices });
if (r.kind !== 'applied') return;
setNodes((p) => p.map((n) => n.id === overNodeId && n.kind === 'decky'
? { ...n, services: nextServices }
: n));
} catch (err) {
flashErr(err, 'update services failed');
}
}
},
[api, archetypes, editor, flashErr, nets, nodes, topologyId],
);
/* ── Cross-net reparent via node drag (detach + attach edge) ─── */
const onReparent = useCallback(async (nodeId: string, fromNetId: string, toNetId: string) => {
if (!topologyId) return;
try {
const { data: detail } = await axios.get(`/topologies/${topologyId}`);
const existingEdge = (detail.edges ?? []).find(
(e: { decky_uuid: string; lan_id: string; id: string }) =>
e.decky_uuid === nodeId && e.lan_id === fromNetId,
);
const node = nodes.find((n) => n.id === nodeId);
const fromNet = nets.find((n) => n.id === fromNetId);
const toNet = nets.find((n) => n.id === toNetId);
const nodeName = node?.kind === 'decky' ? node.name : '';
if (existingEdge) {
await editor.detachEdge(topologyId, existingEdge.id, nodeName, fromNet?.name ?? '');
}
await editor.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId }, nodeName, toNet?.name ?? '');
} catch (err) {
flashErr(err, 'reparent failed');
}
}, [editor, flashErr, nets, nodes, topologyId]);
/* Port→port edges:
* - Same-LAN: visual-only (no bridge to create).
* - Cross-LAN: promote the source decky to multi-home into the
* target LAN via attachEdge. The resulting viz edge carries a
* backendEdgeId so removeEdge can detach it later. Observed
* entities (attacker-pool) are read-only and never bridge. */
const onAddEdge = useCallback(async (fromId: string, toId: string) => {
const fromNode = nodes.find((n) => n.id === fromId);
const toNode = nodes.find((n) => n.id === toId);
if (!fromNode || !toNode) return;
if (fromNode.kind === 'observed' || toNode.kind === 'observed') return;
const dup = edges.some((e) =>
(e.from === fromId && e.to === toId) || (e.from === toId && e.to === fromId),
);
if (dup) return;
const sameLan = fromNode.netId === toNode.netId;
if (sameLan || !topologyId) {
const id = `viz-${fromId}-${toId}-${Date.now()}`;
setEdges((prev) => [...prev, { id, from: fromId, to: toId, traffic: 'active' as const }]);
return;
}
const targetNet = nets.find((n) => n.id === toNode.netId);
if (!targetNet) return;
const fromName = fromNode.kind === 'decky' ? fromNode.name : '';
try {
const res = await editor.attachEdge(
topologyId,
{ decky_uuid: fromId, lan_id: toNode.netId, is_bridge: true },
fromName,
targetNet.name,
);
const backendEdgeId = res.kind === 'applied' ? res.data.id : `enqueued:${res.mutationId}`;
const id = `viz-${fromId}-${toId}-${Date.now()}`;
setEdges((prev) => [
...prev,
{ id, from: fromId, to: toId, traffic: 'active' as const, backendEdgeId },
]);
pushToast({
text: `BRIDGED ${fromName.toUpperCase()}${targetNet.label.toUpperCase()}`,
tone: 'violet',
icon: 'terminal',
});
} catch (err) {
flashErr(err, 'bridge failed');
}
}, [edges, editor, flashErr, nets, nodes, pushToast, topologyId]);
const interaction = useMazeInteraction({
nets, nodes, setNets, setNodes, canvasRef,
onPaletteDrop, onReparent, onAddEdge,
});
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
const removeNet = async (id: string) => {
const net = nets.find((n) => n.id === id);
if (!net || net.kind === 'internet') return;
/* Cascade delete members first — backend will otherwise 400 on orphan risk. */
const members = nodes.filter((n) => n.netId === id && n.kind === 'decky');
try {
for (const m of members) {
const mName = m.kind === 'decky' ? m.name : '';
await editor.deleteDecky(topologyId, m.id, mName);
}
await editor.deleteLan(topologyId, id, net.name);
setNets((p) => p.filter((n) => n.id !== id));
setNodes((p) => p.filter((n) => n.netId !== id));
setEdges((p) => p.filter((e) => {
const a = nodes.find((x) => x.id === e.from)?.netId;
const b = nodes.find((x) => x.id === e.to)?.netId;
return a !== id && b !== id;
}));
setSelection(null);
} catch (err) {
flashErr(err, 'delete network failed');
}
};
const removeNode = async (id: string) => {
const node = nodes.find((n) => n.id === id);
if (!node || node.kind === 'observed') return;
if (node.kind === 'decky' && node.decky_config?.forwards_l3) return;
try {
await editor.deleteDecky(topologyId, id, node.kind === 'decky' ? node.name : '');
setNodes((p) => p.filter((n) => n.id !== id));
setEdges((p) => p.filter((e) => e.from !== id && e.to !== id));
setSelection(null);
} catch (err) {
flashErr(err, 'delete decky failed');
}
};
const removeEdge = async (id: string) => {
const edge = edges.find((e) => e.id === id);
if (!edge) return;
/* Viz-only edges (same-LAN, pre-bridge era, or attach still in
* flight without a backing id) just drop from local state. */
if (!edge.backendEdgeId || !topologyId) {
setEdges((p) => p.filter((e) => e.id !== id));
setSelection(null);
return;
}
/* Cross-LAN bridge: detach the membership edge before removing
* the viz edge. Look the names up from the endpoints so the live
* mutation path has what it needs. */
const fromNode = nodes.find((n) => n.id === edge.from);
const toNode = nodes.find((n) => n.id === edge.to);
const targetNet = toNode ? nets.find((n) => n.id === toNode.netId) : undefined;
const fromName = fromNode?.kind === 'decky' ? fromNode.name : '';
const lanName = targetNet?.name ?? '';
try {
await editor.detachEdge(topologyId, edge.backendEdgeId, fromName, lanName);
setEdges((p) => p.filter((e) => e.id !== id));
setSelection(null);
} catch (err) {
flashErr(err, 'unbridge failed');
}
};
const duplicateNode = async (id: string) => {
const n = nodes.find((x) => x.id === id);
if (!n || n.kind !== 'decky') return;
const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`;
try {
const parentNet = nets.find((net) => net.id === n.netId);
const dRes = await editor.addDeckyToLan(
topologyId,
{ name, services: [...n.services], x: n.x + 24, y: n.y + 24,
decky_config: { archetype: n.archetype } },
n.netId, parentNet?.name ?? '',
);
if (dRes.kind !== 'applied') return;
const decky = dRes.data;
const copy: DeckyNode = {
kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name,
archetype: n.archetype, services: [...n.services], status: 'idle',
x: n.x + 24, y: n.y + 24,
};
setNodes((p) => [...p, copy]);
} catch (err) {
flashErr(err, 'duplicate failed');
}
};
const removeServiceFromNode = async (id: string, slug: string) => {
const n = nodes.find((x) => x.id === id);
if (!n || n.kind !== 'decky' || !n.services.includes(slug)) return;
const nextServices = n.services.filter((s) => s !== slug);
try {
const r = await editor.updateDecky(topologyId, id, n.name, { services: nextServices });
if (r.kind !== 'applied') return;
setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky'
? { ...x, services: nextServices } : x));
setSelection(null);
} catch (err) {
flashErr(err, 'remove service failed');
}
};
const addServiceToNode = async (id: string, slug: string) => {
const n = nodes.find((x) => x.id === id);
if (!n || n.kind !== 'decky' || n.services.includes(slug)) return;
const nextServices = [...n.services, slug];
try {
const r = await editor.updateDecky(topologyId, id, n.name, { services: nextServices });
if (r.kind !== 'applied') return;
setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky'
? { ...x, services: nextServices } : x));
} catch (err) {
flashErr(err, 'add service failed');
}
};
/* Force-mutate is a no-op against a pending topology (no live containers).
* Keep the menu item disabled for now; real hook lands with live-editing polish. */
const forceMutate = (_id: string) => {
flashErr(null, 'force-mutate only applies to deployed topologies');
};
const onNodeContextMenu = (id: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const node = nodes.find((n) => n.id === id);
if (!node) return;
setSelection({ type: 'node', id });
const isObs = node.kind === 'observed';
const isGateway = node.kind === 'decky' && !!node.decky_config?.forwards_l3;
const locked = isObs || isGateway;
const lockedTitle = isObs
? 'observed entity — not a deployed decky'
: isGateway ? 'DMZ gateway — pinned to its DMZ network' : undefined;
const usedServices = node.kind === 'decky' ? new Set(node.services) : new Set<string>();
const serviceSubmenu: MenuItem[] = services
.filter((s) => !usedServices.has(s.slug))
.slice(0, 16)
.map((s) => ({
label: `${s.name} · ${s.proto.toUpperCase()}:${s.port}`,
disabled: isObs,
onClick: () => addServiceToNode(id, s.slug),
}));
if (serviceSubmenu.length === 0) {
serviceSubmenu.push({ label: '(no free services)', disabled: true });
}
setCtxMenu({
x: e.clientX, y: e.clientY,
items: [
{ label: 'Add service…', icon: <Plus size={12} />, disabled: isObs,
title: isObs ? 'observed entity — services fixed' : undefined,
submenu: serviceSubmenu },
{ label: 'Force mutate', icon: <Zap size={12} />, disabled: isObs,
onClick: () => forceMutate(id) },
{ label: 'Duplicate decky', icon: <Copy size={12} />, disabled: locked,
title: lockedTitle, onClick: () => duplicateNode(id) },
{ separator: true, label: '' },
{ label: 'Delete decky', icon: <Trash2 size={12} />, danger: true,
disabled: locked, title: lockedTitle,
onClick: () => removeNode(id) },
],
});
};
const onNetContextMenu = (id: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const net = nets.find((n) => n.id === id);
if (!net) return;
setSelection({ type: 'net', id });
const archetypeSubmenu: MenuItem[] = archetypes.map((a) => ({
label: a.name, icon: <Server size={12} />,
onClick: async () => {
const name = `decky-${hex4()}`;
try {
const dRes = await editor.addDeckyToLan(
topologyId,
{ name, services: [...a.services], x: 20, y: 40,
decky_config: { archetype: a.slug } },
id, net.name,
);
if (dRes.kind !== 'applied') return;
const decky = dRes.data;
const node: DeckyNode = {
kind: 'decky', id: decky.uuid, netId: id, name: decky.name,
archetype: a.slug, services: [...a.services], status: 'idle',
x: 20, y: 40,
};
setNodes((p) => [...p, node]);
} catch (err) {
flashErr(err, 'create decky failed');
}
},
}));
setCtxMenu({
x: e.clientX, y: e.clientY,
items: [
{ label: 'Add decky…', icon: <Plus size={12} />, submenu: archetypeSubmenu },
{ label: 'Inspect', icon: <Eye size={12} />, onClick: () => setSelection({ type: 'net', id }) },
{ separator: true, label: '' },
{ label: net.kind === 'dmz' ? 'Delete DMZ' : 'Delete network',
icon: <Trash2 size={12} />, danger: true,
disabled: net.kind === 'internet',
title: net.kind === 'internet' ? 'internet zone cannot be removed' : undefined,
onClick: () => removeNet(id) },
],
});
};
const onEdgeContextMenu = (id: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setSelection({ type: 'edge', id });
setCtxMenu({
x: e.clientX, y: e.clientY,
items: [
{ label: 'Remove edge', icon: <Trash2 size={12} />, danger: true, onClick: () => removeEdge(id) },
],
});
};
const onCanvasContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setCtxMenu({
x: e.clientX, y: e.clientY,
items: [
{ label: 'Add subnet here', icon: <GitMerge size={12} />,
onClick: () => {
const rect = canvasRef.current?.getBoundingClientRect();
const wx = e.clientX - (rect?.left ?? 0) - interaction.pan.x;
const wy = e.clientY - (rect?.top ?? 0) - interaction.pan.y;
onPaletteDrop(
{ kind: 'network-subnet', slug: 'subnet', label: 'SUBNET', clientX: e.clientX, clientY: e.clientY },
{ x: wx, y: wy }, null, null,
);
},
},
{ label: 'Add DMZ here', icon: <ShieldAlert size={12} />,
onClick: () => {
const rect = canvasRef.current?.getBoundingClientRect();
const wx = e.clientX - (rect?.left ?? 0) - interaction.pan.x;
const wy = e.clientY - (rect?.top ?? 0) - interaction.pan.y;
onPaletteDrop(
{ kind: 'network-dmz', slug: 'dmz', label: 'DMZ', clientX: e.clientX, clientY: e.clientY },
{ x: wx, y: wy }, null, null,
);
},
},
],
});
};
/* Load catalogs. */
useEffect(() => {
let cancelled = false;
api.getServices().then((s) => { if (!cancelled) setServices(s); }).catch(() => {});
api.getArchetypes().then((a) => { if (!cancelled) setArchetypes(a); }).catch(() => {});
return () => { cancelled = true; };
}, [api]);
/* Hydrate topology. Route guard in App.tsx ensures topologyId is set;
* if the id is bogus, surface a friendly error. */
const refetch = useCallback(async () => {
if (!topologyId) return;
try {
const h = await api.getTopology(topologyId);
setNets(h.nets); setNodes(h.nodes); setEdges(h.edges);
setTopoStatus(h.topology.status);
setTopoName(h.topology.name);
setTopoVersion(h.topology.version);
setTopoMode(h.topology.mode ?? 'unihost');
setTopoTargetHost(h.topology.target_host_uuid ?? null);
setLoadErr(null);
} catch (err) {
setLoadErr((err as Error)?.message ?? 'topology load failed');
}
}, [api, topologyId]);
useEffect(() => { refetch(); }, [refetch]);
/* Live topology stream. Open only when the topology is deployed —
* pending topologies have no mutator loop and would just idle on
* keepalives. On any state-transition event we refetch; DB is the
* source of truth and the bus is at-most-once. */
const [streamLive, setStreamLive] = useState(false);
const [lastEventAt, setLastEventAt] = useState<Date | null>(null);
const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded';
const onStreamEvent = useCallback((event: TopologyStreamEvent) => {
// Flip LIVE only on named, purposeful events — not incidental keepalives.
if (event.name === 'snapshot'
|| event.name.startsWith('mutation.')
|| event.name === 'status') {
setStreamLive(true);
setLastEventAt(new Date());
}
if (event.name === 'mutation.failed') {
const p = event.payload ?? {};
const reason = typeof p.reason === 'string' ? p.reason
: typeof p.error === 'string' ? p.error
: 'mutation failed — check mutator logs';
setActionErr(`mutation failed: ${reason}`);
setTimeout(() => setActionErr(null), 6000);
}
if (event.name === 'mutation.applied'
|| event.name === 'mutation.failed'
|| event.name === 'status') {
refetch();
}
}, [refetch]);
const onStreamError = useCallback(() => { setStreamLive(false); }, []);
useTopologyStream({
topologyId: streamEnabled ? topologyId : null,
enabled: streamEnabled,
onEvent: onStreamEvent,
onError: onStreamError,
});
useEffect(() => { if (!streamEnabled) setStreamLive(false); }, [streamEnabled]);
const onDeploy = async () => {
if (!topologyId) return;
setDeploying(true);
try {
await api.deployTopology(topologyId);
await refetch();
} catch (err) {
flashErr(err, 'deploy failed');
} finally {
setDeploying(false);
}
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setSelection(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const canDeploy = topoStatus === 'pending' && nets.length > 0;
const deckyNodes = nodes.filter((n) => n.kind === 'decky');
const runningDeckies = deckyNodes.filter((n) => n.status === 'active').length;
return (
<div className="maze-page">
<div className="maze-page-header">
<div>
<h1>MAZENET · {topoName || topologyId}</h1>
<div className="maze-page-sub">
NETWORK OF NETWORKS · {topoStatus.toUpperCase()} · v{topoVersion} ·{' '}
HOST:{' '}
{topoMode === 'agent' && topoTargetHost ? (
<span title={topoTargetHost}>
<Server size={11} style={{ marginRight: 3, verticalAlign: '-1px' }} />
{hostsByUuid.get(topoTargetHost)?.name ?? topoTargetHost.slice(0, 8)}
</span>
) : (
<span>MASTER</span>
)}
{' · '}
{nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS ·{' '}
{runningDeckies}/{deckyNodes.length} DECKIES RUNNING
{streamEnabled && (
<span className="alert-text" style={{ color: streamLive ? undefined : 'var(--fg-dim)' }}>
{' '}· {streamLive ? 'LIVE' : 'CONNECTING…'}
</span>
)}
{loadErr && <span className="alert-text"> · {loadErr}</span>}
{actionErr && <span className="alert-text"> · {actionErr}</span>}
</div>
</div>
<div className="maze-page-actions">
<button type="button" className="maze-btn ghost" onClick={() => navigate('/mazenet')}>
<ArrowLeft size={12} /> TOPOLOGIES
</button>
<button type="button" className="maze-btn ghost" onClick={() => setPaletteOpen((o) => !o)}>
{paletteOpen ? <PanelLeftClose size={12} /> : <PanelLeftOpen size={12} />} SERVICE FLEET
</button>
<button type="button" className="maze-btn ghost" onClick={() => setInspectorOpen((o) => !o)}>
{inspectorOpen ? <PanelRightClose size={12} /> : <PanelRightOpen size={12} />} INSPECTOR
</button>
<button
type="button"
className="maze-btn ghost"
onClick={() => setFullscreen((f) => !f)}
title={fullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen canvas'}
>
{fullscreen ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
{fullscreen ? ' EXIT FULL' : ' FULLSCREEN'}
</button>
<button type="button" className="maze-btn ghost" onClick={refetch} title="Revert local state to server">
<RotateCcw size={12} /> REFRESH
</button>
<button
type="button"
className="maze-btn ghost"
onClick={() => navigate(`/topologies/${topologyId}/personas`)}
disabled={!topologyId}
title="Edit email personas for this topology"
>
<Mail size={12} /> PERSONAS
</button>
<button
type="button"
className="maze-btn"
disabled={!canDeploy || deploying}
onClick={onDeploy}
title={canDeploy ? 'Deploy topology' : 'Deploy requires pending status + at least one network'}
>
<UploadCloud size={12} /> {deploying ? 'DEPLOYING…' : 'DEPLOY'}
</button>
</div>
</div>
<div
className="maze-shell"
style={{
gridTemplateColumns: `${paletteOpen ? '240px' : '0px'} 1fr ${inspectorOpen ? '320px' : '0px'}`,
}}
>
<Palette
services={services}
archetypes={archetypes}
startPaletteDrag={interaction.startPaletteDrag}
className={paletteOpen ? '' : 'collapsed'}
/>
<Canvas
ref={canvasRef}
nets={nets}
nodes={nodes}
edges={edges}
deployed={topoStatus === 'active' || topoStatus === 'degraded'}
selection={selection}
setSelection={setSelection}
pan={interaction.pan}
zoom={interaction.zoom}
dropTargetId={interaction.dropTargetId}
dragging={interaction.dragging}
edgeDraw={interaction.edgeDraw}
onCanvasMouseDown={interaction.onCanvasMouseDown}
onNodeMouseDown={interaction.onNodeMouseDown}
onNetMouseDown={interaction.onNetMouseDown}
onNetResizeMouseDown={interaction.onNetResizeMouseDown}
onPortMouseDown={interaction.onPortMouseDown}
onNodeContextMenu={onNodeContextMenu}
onNetContextMenu={onNetContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
onCanvasContextMenu={onCanvasContextMenu}
onResetView={interaction.resetPan}
onAutoLayout={() => pushToast({ text: 'AUTO-LAYOUT COMING SOON', tone: 'violet', icon: 'info' })}
onZoomIn={() => interaction.zoomBy(1.2)}
onZoomOut={() => interaction.zoomBy(1 / 1.2)}
sseConnected={streamLive}
lastEventAt={lastEventAt}
onSelectService={(nodeId, slug) => setSelection({ type: 'service', id: slug, nodeId })}
panLayerRef={interaction.panLayerRef}
gridPatternRef={interaction.gridPatternRef}
/>
{ctxMenu && (
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
)}
{interaction.paletteDrag && (
<div
className="palette-ghost"
style={{ left: interaction.paletteDrag.clientX + 8, top: interaction.paletteDrag.clientY + 8 }}
>
{interaction.paletteDrag.label}
</div>
)}
<Inspector
selection={selection}
setSelection={setSelection}
nets={nets}
nodes={nodes}
edges={edges}
topologyStatus={topoStatus}
onClose={() => setInspectorOpen(false)}
onDeleteNet={removeNet}
onDeleteNode={removeNode}
onDeleteEdge={removeEdge}
onRemoveService={removeServiceFromNode}
onAddDecky={(netId) => {
const net = nets.find((n) => n.id === netId);
if (!net) return;
onPaletteDrop(
{ kind: 'archetype', slug: archetypes[0]?.slug ?? 'deaddeck',
services: archetypes[0]?.services.slice(0, 2) ?? [],
label: archetypes[0]?.name ?? 'DECKY',
clientX: 0, clientY: 0 },
{ x: net.x + 40, y: net.y + 60 }, netId, null,
);
}}
className={inspectorOpen ? '' : 'collapsed'}
/>
</div>
</div>
);
};
export default MazeNET;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { Globe, GitMerge, ShieldAlert } from '../../icons';
import type { Net } from './types';
import type { ResizeHandle } from './useMazeInteraction';
interface Props {
net: Net;
selected: boolean;
dropTarget: boolean;
inactive: boolean;
deployed?: boolean;
onSelect?: (id: string) => void;
onHeaderMouseDown?: (id: string) => (e: React.MouseEvent) => void;
onResizeMouseDown?: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent) => void;
children?: React.ReactNode;
}
const NetBox: React.FC<Props> = ({
net, selected, dropTarget, inactive, deployed, onSelect, onHeaderMouseDown, onResizeMouseDown, onContextMenu, children,
}) => {
const classes = [
'maze-net-box',
net.kind === 'internet' ? 'internet' : '',
net.kind === 'dmz' ? 'dmz' : '',
selected ? 'selected' : '',
dropTarget ? 'drop-target' : '',
inactive ? 'inactive' : '',
deployed ? 'deployed' : '',
net.pending ? 'pending' : '',
].filter(Boolean).join(' ');
const Icon = net.kind === 'internet' ? Globe : net.kind === 'dmz' ? ShieldAlert : GitMerge;
const resizable = net.kind !== 'internet';
const handleBoxDown = (e: React.MouseEvent) => {
if (e.target !== e.currentTarget) return;
onSelect?.(net.id);
};
const handleHeadDown = (e: React.MouseEvent) => {
onSelect?.(net.id);
onHeaderMouseDown?.(net.id)(e);
};
return (
<div
className={classes}
style={{ left: net.x, top: net.y, width: net.w, height: net.h }}
onMouseDown={handleBoxDown}
onContextMenu={onContextMenu}
>
<div className="maze-net-box-head" onMouseDown={handleHeadDown}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Icon size={10} />
<span>{net.label}</span>
{inactive && !net.pending && (
<span className="chip-mini"
style={{ marginLeft: 4, borderColor: 'var(--border)', color: 'rgba(255,255,255,0.45)' }}>
INACTIVE
</span>
)}
{net.pending && (
<span className="chip-mini"
style={{ marginLeft: 4,
borderColor: 'var(--warn, #e0a040)',
color: 'var(--warn, #e0a040)' }}>
PENDING
</span>
)}
</div>
<span className="cidr">{net.cidr}</span>
</div>
{resizable && onResizeMouseDown && (
<>
<div className="net-resize net-resize-e" onMouseDown={onResizeMouseDown(net.id, 'e')} />
<div className="net-resize net-resize-w" onMouseDown={onResizeMouseDown(net.id, 'w')} />
<div className="net-resize net-resize-s" onMouseDown={onResizeMouseDown(net.id, 's')} />
<div className="net-resize net-resize-n" onMouseDown={onResizeMouseDown(net.id, 'n')} />
<div className="net-resize net-resize-se" onMouseDown={onResizeMouseDown(net.id, 'se')} />
<div className="net-resize net-resize-sw" onMouseDown={onResizeMouseDown(net.id, 'sw')} />
<div className="net-resize net-resize-ne" onMouseDown={onResizeMouseDown(net.id, 'ne')} />
<div className="net-resize net-resize-nw" onMouseDown={onResizeMouseDown(net.id, 'nw')} />
</>
)}
{children}
</div>
);
};
export default React.memo(NetBox);

View File

@@ -0,0 +1,103 @@
import React from 'react';
import {
Server, Monitor, Shield, Database, Cpu, Globe, Users, HardDrive, Eye,
type LucideIcon,
} from '../../icons';
import type { MazeNode } from './types';
import { DEFAULT_SERVICES } from './data';
const ARCHETYPE_ICONS: Record<string, LucideIcon> = {
'linux-server': Server,
'windows-workstation': Monitor,
'domain-controller': Shield,
'database-server': Database,
'iot-device': Cpu,
'web-application': Globe,
'deaddeck': HardDrive,
'attacker-pool': Eye,
'directory-services': Users,
};
interface Props {
node: MazeNode;
absX: number;
absY: number;
selected: boolean;
dragging?: boolean;
deployed?: boolean;
selectedServiceSlug?: string | null;
onSelect?: (id: string) => void;
onSelectService?: (nodeId: string, slug: string) => void;
onMouseDown?: (id: string) => (e: React.MouseEvent) => void;
onPortMouseDown?: (id: string) => (e: React.MouseEvent) => void;
onContextMenu?: (id: string) => (e: React.MouseEvent) => void;
}
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, deployed, selectedServiceSlug, onSelect, onSelectService, onMouseDown, onPortMouseDown, onContextMenu }) => {
const isDmzGateway = !!(node as { decky_config?: { forwards_l3?: boolean } }).decky_config?.forwards_l3;
const classes = [
'maze-node',
node.kind === 'observed' ? 'observed' : '',
node.status === 'hot' ? 'hot' : '',
selected ? 'selected' : '',
dragging ? 'dragging' : '',
deployed ? 'deployed' : '',
deployed && isDmzGateway ? 'dmz-gateway' : '',
].filter(Boolean).join(' ');
const handleDown = (e: React.MouseEvent) => {
onSelect?.(node.id);
onMouseDown?.(node.id)(e);
};
return (
<div
className={classes}
style={{ left: absX, top: absY }}
onMouseDown={handleDown}
onContextMenu={onContextMenu?.(node.id)}
>
<div className="mn-head">
<span className={`status-dot ${node.status}`} />
{(() => {
const Icon = ARCHETYPE_ICONS[node.archetype] ?? Server;
return <Icon size={10} className="mn-head-icon" />;
})()}
<span className="mn-head-name">{node.name}</span>
</div>
<div className="mn-sub">{node.archetype.toUpperCase()}</div>
{node.services.length > 0 && (
<div className="mn-services">
{node.services.map((s) => {
const meta = DEFAULT_SERVICES.find((x) => x.slug === s);
const isHigh = meta?.risk === 'high' || node.status === 'hot';
const isSel = selectedServiceSlug === s;
return (
<span
key={s}
className={`service-tag ${isHigh ? 'hot' : ''} ${isSel ? 'service-selected' : ''}`}
title={meta ? `${meta.name} · ${meta.proto.toUpperCase()}:${meta.port}` : s}
onMouseDown={(e) => {
if (!onSelectService) return;
e.stopPropagation();
onSelectService(node.id, s);
}}
>
{s}
</span>
);
})}
</div>
)}
{node.kind === 'decky' && <>
<span className="mn-port in" />
<span className="mn-port out" onMouseDown={onPortMouseDown?.(node.id)} />
</>}
{node.kind === 'observed' && (
<span className="mn-port out" onMouseDown={onPortMouseDown?.(node.id)} />
)}
</div>
);
};
export default React.memo(NodeCard);

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { GitMerge, ShieldAlert, Server, Monitor, Shield, Database, Cpu, Globe,
Terminal, Lock, Folder, HardDrive, Users, KeyRound,
Radio, Zap, Wifi, Circle, Mail, Phone, Activity, Box } from '../../icons';
import type { ServiceDef, Archetype, ServiceGroup } from './data';
import { SERVICE_GROUP_ORDER } from './data';
import type { PaletteDrag } from './useMazeInteraction';
const ICON: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
'git-merge': GitMerge, 'shield-alert': ShieldAlert,
server: Server, monitor: Monitor, shield: Shield,
database: Database, cpu: Cpu, globe: Globe, terminal: Terminal, lock: Lock,
folder: Folder, 'hard-drive': HardDrive, users: Users, 'key-round': KeyRound,
radio: Radio, zap: Zap, wifi: Wifi, circle: Circle,
mail: Mail, phone: Phone, activity: Activity, box: Box,
};
function Icon({ name, size = 14, className }: { name: string; size?: number; className?: string }) {
const C = ICON[name] ?? Circle;
return <C size={size} className={className} />;
}
interface Props {
services: ServiceDef[];
archetypes: Archetype[];
startPaletteDrag: (d: Omit<PaletteDrag, 'clientX' | 'clientY'>, e: React.MouseEvent) => void;
className?: string;
}
const Palette: React.FC<Props> = ({ services, archetypes, startPaletteDrag, className = '' }) => {
const start = (d: Omit<PaletteDrag, 'clientX' | 'clientY'>) =>
(e: React.MouseEvent) => {
if (e.button !== 0) return;
e.preventDefault();
startPaletteDrag(d, e);
};
return (
<div className={`maze-palette ${className}`}>
<div className="palette-group">
<label> NETWORKS</label>
<div className="palette-item" onMouseDown={start({ kind: 'network-subnet', slug: 'subnet', label: 'SUBNET' })}>
<Icon name="git-merge" className="violet-accent" />
<span>Subnet</span>
<span className="chip-mini">VLAN</span>
</div>
<div className="palette-item" onMouseDown={start({ kind: 'network-dmz', slug: 'dmz', label: 'DMZ' })}>
<Icon name="shield-alert" className="alert-text" />
<span>DMZ</span>
<span className="chip-mini">HOST</span>
</div>
</div>
<div className="palette-group">
<label> ARCHETYPES</label>
{archetypes.map((a: Archetype) => (
<div
key={a.slug}
className="palette-item"
onMouseDown={start({ kind: 'archetype', slug: a.slug, label: a.name, services: a.services })}
>
<Icon name={a.icon} className="violet-accent" />
<span>{a.name}</span>
<span className="chip-mini">{a.services.length}</span>
</div>
))}
</div>
<div className="palette-group">
<label> SERVICES</label>
{(() => {
const byGroup = new Map<ServiceGroup, ServiceDef[]>();
for (const s of services) {
const g = (s.group ?? 'Miscellaneous') as ServiceGroup;
const list = byGroup.get(g) ?? [];
list.push(s);
byGroup.set(g, list);
}
const extras = [...byGroup.keys()].filter((g) => !SERVICE_GROUP_ORDER.includes(g));
const order = [...SERVICE_GROUP_ORDER, ...extras];
return order
.filter((g) => byGroup.has(g))
.map((g) => (
<div key={g} className="palette-subgroup">
<div className="palette-subgroup-label">{g.toUpperCase()}</div>
{byGroup.get(g)!.map((s) => (
<div
key={s.slug}
className="palette-item"
onMouseDown={start({ kind: 'service', slug: s.slug, label: s.name })}
>
<Icon
name={s.icon}
size={12}
className={s.risk === 'high' ? 'alert-text' : s.risk === 'med' ? 'violet-accent' : 'matrix-text'}
/>
<span>{s.name}</span>
<span className="chip-mini">{s.proto.toUpperCase()}/{s.port}</span>
</div>
))}
</div>
));
})()}
</div>
<div className="palette-group">
<label>HINT</label>
<div className="palette-hint">
Drag a network onto the canvas, or an archetype onto a network,
or a service onto a decky. Right-click for menus.
</div>
</div>
</div>
);
};
export default Palette;

View File

@@ -0,0 +1,98 @@
export interface Archetype {
slug: string;
name: string;
services: string[];
icon: string;
}
export interface ServiceDef {
slug: string;
name: string;
port: number;
proto: 'tcp' | 'udp';
icon: string;
risk: 'low' | 'med' | 'high';
group: ServiceGroup;
}
export type ServiceGroup =
| 'Remote Access'
| 'Web'
| 'File Transfer'
| 'Directory'
| 'Databases'
| 'Mail'
| 'Communications'
| 'IoT / OT'
| 'Observability'
| 'Containers'
| 'Miscellaneous';
// Rendering order for the palette.
export const SERVICE_GROUP_ORDER: ServiceGroup[] = [
'Remote Access',
'Web',
'File Transfer',
'Directory',
'Databases',
'Mail',
'Communications',
'IoT / OT',
'Observability',
'Containers',
'Miscellaneous',
];
export const ARCHETYPES: Archetype[] = [
{ slug: 'linux-server', name: 'Linux Server', services: ['ssh', 'http'], icon: 'server' },
{ slug: 'windows-workstation', name: 'Windows Workstation', services: ['smb', 'rdp'], icon: 'monitor' },
{ slug: 'domain-controller', name: 'Domain Controller', services: ['smb', 'rdp', 'ldap', 'llmnr', 'kerberos'], icon: 'shield' },
{ slug: 'database-server', name: 'Database Server', services: ['mysql', 'postgres', 'redis'], icon: 'database' },
{ slug: 'iot-device', name: 'IoT / OT Device', services: ['modbus', 'mqtt', 'coap'], icon: 'cpu' },
{ slug: 'web-application', name: 'Web Application', services: ['http', 'https'], icon: 'globe' },
];
export const DEFAULT_SERVICES: ServiceDef[] = [
// Remote Access
{ slug: 'ssh', name: 'SSH', port: 22, proto: 'tcp', icon: 'terminal', risk: 'high', group: 'Remote Access' },
{ slug: 'telnet', name: 'Telnet', port: 23, proto: 'tcp', icon: 'terminal', risk: 'high', group: 'Remote Access' },
{ slug: 'rdp', name: 'RDP', port: 3389, proto: 'tcp', icon: 'monitor', risk: 'high', group: 'Remote Access' },
{ slug: 'vnc', name: 'VNC', port: 5900, proto: 'tcp', icon: 'monitor', risk: 'high', group: 'Remote Access' },
// Web
{ slug: 'http', name: 'HTTP', port: 80, proto: 'tcp', icon: 'globe', risk: 'med', group: 'Web' },
{ slug: 'https', name: 'HTTPS', port: 443, proto: 'tcp', icon: 'lock', risk: 'med', group: 'Web' },
// File Transfer
{ slug: 'ftp', name: 'FTP', port: 21, proto: 'tcp', icon: 'folder', risk: 'high', group: 'File Transfer' },
{ slug: 'tftp', name: 'TFTP', port: 69, proto: 'udp', icon: 'folder', risk: 'high', group: 'File Transfer' },
{ slug: 'smb', name: 'SMB', port: 445, proto: 'tcp', icon: 'hard-drive', risk: 'high', group: 'File Transfer' },
// Directory
{ slug: 'ldap', name: 'LDAP', port: 389, proto: 'tcp', icon: 'users', risk: 'med', group: 'Directory' },
{ slug: 'kerberos', name: 'Kerberos', port: 88, proto: 'tcp', icon: 'key-round', risk: 'med', group: 'Directory' },
{ slug: 'llmnr', name: 'LLMNR', port: 5355, proto: 'udp', icon: 'radio', risk: 'low', group: 'Directory' },
// Databases
{ slug: 'mysql', name: 'MySQL', port: 3306, proto: 'tcp', icon: 'database', risk: 'high', group: 'Databases' },
{ slug: 'postgres', name: 'Postgres', port: 5432, proto: 'tcp', icon: 'database', risk: 'high', group: 'Databases' },
{ slug: 'mssql', name: 'MSSQL', port: 1433, proto: 'tcp', icon: 'database', risk: 'high', group: 'Databases' },
{ slug: 'mongodb', name: 'MongoDB', port: 27017, proto: 'tcp', icon: 'database', risk: 'high', group: 'Databases' },
{ slug: 'redis', name: 'Redis', port: 6379, proto: 'tcp', icon: 'zap', risk: 'med', group: 'Databases' },
// Mail
{ slug: 'smtp', name: 'SMTP', port: 25, proto: 'tcp', icon: 'mail', risk: 'med', group: 'Mail' },
{ slug: 'smtp_relay', name: 'SMTP Relay', port: 587, proto: 'tcp', icon: 'mail', risk: 'med', group: 'Mail' },
{ slug: 'imap', name: 'IMAP', port: 143, proto: 'tcp', icon: 'mail', risk: 'med', group: 'Mail' },
{ slug: 'pop3', name: 'POP3', port: 110, proto: 'tcp', icon: 'mail', risk: 'med', group: 'Mail' },
// Communications
{ slug: 'sip', name: 'SIP', port: 5060, proto: 'udp', icon: 'phone', risk: 'med', group: 'Communications' },
// IoT / OT
{ slug: 'mqtt', name: 'MQTT', port: 1883, proto: 'tcp', icon: 'wifi', risk: 'low', group: 'IoT / OT' },
{ slug: 'modbus', name: 'Modbus', port: 502, proto: 'tcp', icon: 'cpu', risk: 'med', group: 'IoT / OT' },
{ slug: 'coap', name: 'CoAP', port: 5683, proto: 'udp', icon: 'wifi', risk: 'low', group: 'IoT / OT' },
{ slug: 'conpot', name: 'Conpot (ICS)', port: 102, proto: 'tcp', icon: 'cpu', risk: 'med', group: 'IoT / OT' },
// Observability
{ slug: 'elasticsearch', name: 'Elasticsearch', port: 9200, proto: 'tcp', icon: 'activity', risk: 'med', group: 'Observability' },
{ slug: 'snmp', name: 'SNMP', port: 161, proto: 'udp', icon: 'activity', risk: 'low', group: 'Observability' },
// Containers
{ slug: 'docker_api', name: 'Docker API', port: 2375, proto: 'tcp', icon: 'box', risk: 'high', group: 'Containers' },
{ slug: 'k8s', name: 'Kubernetes', port: 6443, proto: 'tcp', icon: 'box', risk: 'high', group: 'Containers' },
{ slug: 'registry', name: 'Registry', port: 5000, proto: 'tcp', icon: 'box', risk: 'med', group: 'Containers' },
];

View File

@@ -0,0 +1,64 @@
export type NetKind = 'internet' | 'subnet' | 'dmz';
export interface Net {
id: string;
/** Display string (uppercased for the canvas chrome). */
label: string;
/** Canonical LAN name as stored on the backend — lowercase. Use
* this (not ``label``) for any API call that identifies a LAN by
* name (mutator attach/detach, delete, etc.); the mutator looks
* up case-sensitively and will 404 on the uppercased form. */
name: string;
cidr: string;
kind: NetKind;
x: number;
y: number;
w: number;
h: number;
/** Optimistic placeholder for an enqueued mutation on a live
* topology. Replaced on next refetch when the mutator emits the
* applied event. Rendered with an amber tint so the user can tell
* it's a queued add, not a regular non-deployed LAN. */
pending?: boolean;
}
export type NodeKind = 'decky' | 'observed';
interface NodeBase {
id: string;
netId: string;
name: string;
archetype: string;
services: string[];
status: 'active' | 'idle' | 'hot' | 'mutating';
x: number;
y: number;
}
export interface DeckyNode extends NodeBase {
kind: 'decky';
ip?: string;
decky_config?: Record<string, unknown>;
mutate_interval?: number | null;
}
export interface ObservedNode extends NodeBase {
kind: 'observed';
archetype: 'attacker-pool';
services: ['*'];
}
export type MazeNode = DeckyNode | ObservedNode;
export interface Edge {
id: string;
from: string;
to: string;
traffic: 'hot' | 'active' | 'idle';
label?: string;
/** Backend membership-edge id when this visual edge mirrors a
* cross-LAN bridge attachment. Same-LAN edges stay visual-only
* and leave this undefined. Set at attach, consumed at detach. */
backendEdgeId?: string;
}

View File

@@ -0,0 +1,415 @@
import { useCallback, useMemo } from 'react';
import api from '../../utils/api';
import { ARCHETYPES as DEFAULT_ARCHETYPES, DEFAULT_SERVICES } from './data';
import type { Archetype, ServiceDef } from './data';
import type { Net, MazeNode, Edge, DeckyNode } from './types';
import { applyLayout, loadLayout } from './useMazeLayoutStore';
export interface LANRow {
id: string;
topology_id: string;
name: string;
subnet: string;
is_dmz: boolean;
x?: number | null;
y?: number | null;
}
export interface DeckyRow {
uuid: string;
topology_id: string;
name: string;
services: string[];
decky_config?: Record<string, unknown> | null;
ip?: string | null;
state: string;
x?: number | null;
y?: number | null;
}
export interface EdgeRow {
id: string;
topology_id: string;
decky_uuid: string;
lan_id: string;
is_bridge: boolean;
forwards_l3: boolean;
}
export interface TopologySummary {
id: string;
name: string;
mode: string;
target_host_uuid: string | null;
status: string;
version: number;
}
interface TopologyDetail {
topology: TopologySummary;
lans: LANRow[];
deckies: DeckyRow[];
edges: EdgeRow[];
}
export interface HydratedTopology {
topology: TopologySummary;
nets: Net[];
nodes: MazeNode[];
edges: Edge[];
}
/** Adapt the wire shape to canvas entities. Backend edges are
* decky↔LAN membership (bipartite); we surface them as node-in-net
* placement. Decky-to-decky traffic edges are derived from
* shared-LAN co-membership for visualization only. */
export function adaptTopology(detail: TopologyDetail): HydratedTopology {
// Auto-layout: DMZ pinned top-left, subnets flow in a grid to the right.
// We ignore lan.x/lan.y from the backend because canvas position
// persistence is deferred (handled via localStorage in a later pass).
// Computing layout from the graph keeps the canvas readable no matter
// how sloppy the original drop points were.
const NET_W = 300;
const NET_H = 240;
const GAP_X = 40;
const GAP_Y = 40;
const COLS = 3;
const dmzs = detail.lans.filter((l) => l.is_dmz);
const subnets = detail.lans.filter((l) => !l.is_dmz);
const ordered = [...dmzs, ...subnets];
const nets: Net[] = ordered.map((lan, i) => ({
id: lan.id,
name: lan.name,
label: lan.name.toUpperCase(),
cidr: lan.subnet,
kind: lan.is_dmz ? 'dmz' : 'subnet',
x: GAP_X + (i % COLS) * (NET_W + GAP_X),
y: GAP_Y + Math.floor(i / COLS) * (NET_H + GAP_Y),
w: NET_W,
h: NET_H,
}));
// Home LAN = first edge; a multi-homed gateway is drawn inside its
// home LAN, membership in others is expressed via the edge list.
// Gateways (forwards_l3) MUST render inside a DMZ — edge ordering from
// the backend is not guaranteed, so we pick the DMZ edge explicitly.
const dmzIds = new Set(detail.lans.filter((l) => l.is_dmz).map((l) => l.id));
const gatewayUuids = new Set(
detail.edges.filter((e) => e.forwards_l3).map((e) => e.decky_uuid),
);
const firstLanFor = new Map<string, string>();
for (const e of detail.edges) {
if (gatewayUuids.has(e.decky_uuid)) {
// Only accept a DMZ edge as home for a gateway.
if (dmzIds.has(e.lan_id) && !firstLanFor.has(e.decky_uuid)) {
firstLanFor.set(e.decky_uuid, e.lan_id);
}
continue;
}
if (!firstLanFor.has(e.decky_uuid)) firstLanFor.set(e.decky_uuid, e.lan_id);
}
// Layout deckies in a 2-column grid inside their home LAN so two
// members never overlap regardless of backend x/y. Same reasoning as
// the LAN grid above.
const NODE_COL_W = 140;
const NODE_ROW_H = 82;
const NODE_X0 = 12;
const NODE_Y0 = 40;
const perNetIndex = new Map<string, number>();
const nodes: MazeNode[] = detail.deckies.map((d): DeckyNode => {
const homeNetId = firstLanFor.get(d.uuid) ?? (nets[0]?.id ?? '');
const idx = perNetIndex.get(homeNetId) ?? 0;
perNetIndex.set(homeNetId, idx + 1);
return {
kind: 'decky',
id: d.uuid,
netId: homeNetId,
name: d.name,
archetype: (d.decky_config as { archetype?: string } | null)?.archetype ?? 'linux-server',
services: d.services,
status: d.state === 'running' ? 'active' : d.state === 'failed' ? 'hot' : 'idle',
x: NODE_X0 + (idx % 2) * NODE_COL_W,
y: NODE_Y0 + Math.floor(idx / 2) * NODE_ROW_H,
ip: d.ip ?? undefined,
decky_config: d.decky_config ?? undefined,
};
});
const byLan = new Map<string, string[]>();
for (const e of detail.edges) {
const arr = byLan.get(e.lan_id) ?? [];
arr.push(e.decky_uuid);
byLan.set(e.lan_id, arr);
}
const seen = new Set<string>();
const edges: Edge[] = [];
for (const [lanId, members] of byLan) {
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) {
const key = `${members[i]}::${members[j]}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
id: `${lanId}-${members[i]}-${members[j]}`,
from: members[i],
to: members[j],
traffic: 'idle',
});
}
}
}
return { topology: detail.topology, nets, nodes, edges };
}
interface ArchetypeRow {
slug: string;
display_name: string;
description: string;
services: string[];
preferred_distros: string[];
nmap_os: string;
}
const NMAP_OS_TO_ICON: Record<string, string> = {
linux: 'server',
windows: 'monitor',
embedded: 'cpu',
};
export interface CreateLanBody {
name: string;
is_dmz: boolean;
x: number;
y: number;
subnet?: string;
}
export interface CreateDeckyBody {
name: string;
services: string[];
x: number;
y: number;
decky_config?: Record<string, unknown>;
}
export type MutationOp =
| 'add_lan' | 'remove_lan' | 'update_lan'
| 'add_decky' | 'attach_decky' | 'detach_decky' | 'remove_decky' | 'update_decky';
export interface EnqueueMutationResponse {
mutation_id: string;
state: string;
}
export interface MazeApi {
listTopologies: () => Promise<TopologySummary[]>;
createBlankTopology: (name: string) => Promise<TopologySummary>;
getTopology: (id: string) => Promise<HydratedTopology>;
getServices: () => Promise<ServiceDef[]>;
getArchetypes: () => Promise<Archetype[]>;
getNextIp: (topologyId: string, lanId: string) => Promise<string>;
getNextSubnet: (base?: string) => Promise<string>;
createLan: (topologyId: string, body: CreateLanBody) => Promise<LANRow>;
updateLan: (topologyId: string, lanId: string, patch: Partial<LANRow>) => Promise<LANRow>;
deleteLan: (topologyId: string, lanId: string) => Promise<void>;
createDecky: (topologyId: string, body: CreateDeckyBody) => Promise<DeckyRow>;
updateDecky: (topologyId: string, uuid: string, patch: Partial<DeckyRow>) => Promise<DeckyRow>;
deleteDecky: (topologyId: string, uuid: string) => Promise<void>;
attachEdge: (topologyId: string, body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean }) => Promise<EdgeRow>;
detachEdge: (topologyId: string, edgeId: string) => Promise<void>;
enqueueMutation: (
topologyId: string,
op: MutationOp,
payload: Record<string, unknown>,
expectedVersion?: number,
) => Promise<EnqueueMutationResponse>;
deployTopology: (topologyId: string) => Promise<void>;
}
export function useMazeApi(): MazeApi {
const listTopologies = useCallback(async () => {
const { data } = await api.get('/topologies/');
return (data?.data ?? []) as TopologySummary[];
}, []);
const createBlankTopology = useCallback(async (name: string): Promise<TopologySummary> => {
const { data } = await api.post<TopologySummary>('/topologies/blank', { name });
return data;
}, []);
const getTopology = useCallback(async (id: string) => {
const { data } = await api.get<TopologyDetail>(`/topologies/${id}`);
const hydrated = adaptTopology(data);
const layout = loadLayout(id);
const { nets, nodes } = applyLayout(hydrated.nets, hydrated.nodes, layout);
return { ...hydrated, nets, nodes };
}, []);
const getServices = useCallback(async () => {
try {
const { data } = await api.get<{ services: string[] }>('/topologies/services');
const known = new Map(DEFAULT_SERVICES.map((s) => [s.slug, s]));
return data.services.map(
(slug) =>
known.get(slug) ?? {
slug,
name: slug.toUpperCase(),
port: 0,
proto: 'tcp' as const,
icon: 'circle',
risk: 'low' as const,
group: 'Miscellaneous' as const,
},
);
} catch {
return DEFAULT_SERVICES;
}
}, []);
const getArchetypes = useCallback(async (): Promise<Archetype[]> => {
try {
const { data } = await api.get<{ archetypes: ArchetypeRow[] }>('/topologies/archetypes');
const known = new Map(DEFAULT_ARCHETYPES.map((a) => [a.slug, a.icon]));
return data.archetypes.map((a) => ({
slug: a.slug,
name: a.display_name,
services: a.services,
icon: known.get(a.slug) ?? NMAP_OS_TO_ICON[a.nmap_os] ?? 'server',
}));
} catch {
return DEFAULT_ARCHETYPES;
}
}, []);
const getNextIp = useCallback(async (topologyId: string, lanId: string) => {
const { data } = await api.get<{ subnet: string; ip: string }>(
`/topologies/${topologyId}/lans/${lanId}/next-ip`,
);
return data.ip;
}, []);
const getNextSubnet = useCallback(async (base: string = '10.0') => {
const { data } = await api.get<{ subnet: string }>(
`/topologies/next-subnet`,
{ params: { base } },
);
return data.subnet;
}, []);
const createLan = useCallback(
async (topologyId: string, body: CreateLanBody): Promise<LANRow> => {
const { data } = await api.post<LANRow>(`/topologies/${topologyId}/lans`, body);
return data;
},
[],
);
const updateLan = useCallback(
async (topologyId: string, lanId: string, patch: Partial<LANRow>): Promise<LANRow> => {
const { data } = await api.patch<LANRow>(`/topologies/${topologyId}/lans/${lanId}`, patch);
return data;
},
[],
);
const deleteLan = useCallback(
async (topologyId: string, lanId: string): Promise<void> => {
await api.delete(`/topologies/${topologyId}/lans/${lanId}`);
},
[],
);
const createDecky = useCallback(
async (topologyId: string, body: CreateDeckyBody): Promise<DeckyRow> => {
const { data } = await api.post<DeckyRow>(`/topologies/${topologyId}/deckies`, body);
return data;
},
[],
);
const updateDecky = useCallback(
async (topologyId: string, uuid: string, patch: Partial<DeckyRow>): Promise<DeckyRow> => {
const { data } = await api.patch<DeckyRow>(
`/topologies/${topologyId}/deckies/${uuid}`,
patch,
);
return data;
},
[],
);
const deleteDecky = useCallback(
async (topologyId: string, uuid: string): Promise<void> => {
await api.delete(`/topologies/${topologyId}/deckies/${uuid}`);
},
[],
);
const attachEdge = useCallback(
async (topologyId: string, body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean }): Promise<EdgeRow> => {
const { data } = await api.post<EdgeRow>(`/topologies/${topologyId}/edges`, body);
return data;
},
[],
);
const detachEdge = useCallback(
async (topologyId: string, edgeId: string): Promise<void> => {
await api.delete(`/topologies/${topologyId}/edges/${edgeId}`);
},
[],
);
const deployTopology = useCallback(
async (topologyId: string): Promise<void> => {
await api.post(`/topologies/${topologyId}/deploy`, {});
},
[],
);
const enqueueMutation = useCallback(
async (
topologyId: string,
op: MutationOp,
payload: Record<string, unknown>,
expectedVersion?: number,
): Promise<EnqueueMutationResponse> => {
const body: { op: MutationOp; payload: Record<string, unknown>; expected_version?: number } = { op, payload };
if (expectedVersion !== undefined) body.expected_version = expectedVersion;
const { data } = await api.post<EnqueueMutationResponse>(
`/topologies/${topologyId}/mutations`,
body,
);
return data;
},
[],
);
return useMemo(
() => ({
listTopologies, createBlankTopology, getTopology, getServices, getArchetypes,
getNextIp, getNextSubnet,
createLan, updateLan, deleteLan,
createDecky, updateDecky, deleteDecky,
attachEdge, detachEdge,
enqueueMutation,
deployTopology,
}),
[
listTopologies, createBlankTopology, getTopology, getServices, getArchetypes,
getNextIp, getNextSubnet,
createLan, updateLan, deleteLan,
createDecky, updateDecky, deleteDecky,
attachEdge, detachEdge,
enqueueMutation,
deployTopology,
],
);
}

View File

@@ -0,0 +1,424 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Net, MazeNode } from './types';
export type ResizeHandle = 'e' | 'w' | 'n' | 's' | 'ne' | 'nw' | 'se' | 'sw';
export type PaletteDragKind = 'network-subnet' | 'network-dmz' | 'archetype' | 'service';
export interface PaletteDrag {
kind: PaletteDragKind;
slug: string;
label: string;
services?: string[];
clientX: number;
clientY: number;
}
type Drag =
| null
| { type: 'pan'; startX: number; startY: number; panX: number; panY: number }
| { type: 'node'; id: string; offX: number; offY: number }
| { type: 'net'; id: string; offX: number; offY: number }
| { type: 'resize'; id: string; handle: ResizeHandle; startX: number; startY: number; start: Net };
interface Args {
nets: Net[];
nodes: MazeNode[];
setNets: React.Dispatch<React.SetStateAction<Net[]>>;
setNodes: React.Dispatch<React.SetStateAction<MazeNode[]>>;
canvasRef: React.RefObject<HTMLDivElement | null>;
onPaletteDrop?: (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => void;
/** Structural callbacks — only these hit the backend. */
onReparent?: (nodeId: string, fromNetId: string, toNetId: string) => void;
onAddEdge?: (fromNodeId: string, toNodeId: string) => void;
}
interface EdgeDraw {
fromId: string;
fromX: number; fromY: number;
toX: number; toY: number;
hoverTarget: string | null;
}
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 2.5;
export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, onPaletteDrop, onReparent, onAddEdge }: Args) {
const [pan, setPan] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [drag, setDrag] = useState<Drag>(null);
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
const [edgeDraw, setEdgeDraw] = useState<EdgeDraw | null>(null);
const [paletteDrag, setPaletteDrag] = useState<PaletteDrag | null>(null);
const edgeDrawRef = useRef<EdgeDraw | null>(null);
const paletteDragRef = useRef<PaletteDrag | null>(null);
useEffect(() => { edgeDrawRef.current = edgeDraw; }, [edgeDraw]);
useEffect(() => { paletteDragRef.current = paletteDrag; }, [paletteDrag]);
/* DOM refs for the pan/zoom layer and grid pattern. Pan mousemoves
* write transforms here directly via rAF, bypassing React until
* mouseup. This is what keeps a 30-LAN topology from melting the
* browser — React re-rendering hundreds of SVG paths and div cards
* on every mousemove is the dominant cost, and panning doesn't
* mutate any data, only the viewport. */
const panLayerRef = useRef<HTMLDivElement | null>(null);
const gridPatternRef = useRef<SVGPatternElement | null>(null);
const rafHandle = useRef<number | null>(null);
const writeTransform = useCallback(() => {
if (rafHandle.current !== null) return;
rafHandle.current = requestAnimationFrame(() => {
rafHandle.current = null;
const p = panRef.current;
const z = zoomRef.current;
const layer = panLayerRef.current;
if (layer) {
layer.style.transform = `translate(${p.x}px, ${p.y}px) scale(${z})`;
}
const grid = gridPatternRef.current;
if (grid) {
grid.setAttribute('x', String(p.x));
grid.setAttribute('y', String(p.y));
const size = String(40 * z);
grid.setAttribute('width', size);
grid.setAttribute('height', size);
}
});
}, []);
const startPaletteDrag = useCallback((d: Omit<PaletteDrag, 'clientX' | 'clientY'>, e: React.MouseEvent) => {
setPaletteDrag({ ...d, clientX: e.clientX, clientY: e.clientY });
}, []);
/* Refs to avoid re-binding global listeners on every state change. */
const netsRef = useRef(nets);
const nodesRef = useRef(nodes);
const panRef = useRef(pan);
const zoomRef = useRef(zoom);
const dragRef = useRef(drag);
useEffect(() => { netsRef.current = nets; }, [nets]);
useEffect(() => { nodesRef.current = nodes; }, [nodes]);
useEffect(() => { panRef.current = pan; }, [pan]);
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
useEffect(() => { dragRef.current = drag; }, [drag]);
const canvasOriginRef = useRef(() => {
const r = canvasRef.current?.getBoundingClientRect();
return { x: r?.left ?? 0, y: r?.top ?? 0 };
});
/* World-space coords from a client event (applies pan + zoom inverse). */
const toWorld = useCallback((clientX: number, clientY: number) => {
const o = canvasOriginRef.current();
const p = panRef.current;
const z = zoomRef.current;
return { x: (clientX - o.x - p.x) / z, y: (clientY - o.y - p.y) / z };
}, []);
/* ── Mousedown dispatchers ────────────────────────────── */
const onCanvasMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
// Pan starts whenever a left-mousedown bubbles up to the canvas.
// Node/header/resize/port handlers call stopPropagation() and never
// reach here; net-box body mousedowns DO bubble so you can pan from
// "inside" a LAN when zoomed in and no bare grid is visible.
setDrag({ type: 'pan', startX: e.clientX, startY: e.clientY, panX: panRef.current.x, panY: panRef.current.y });
}, []);
const onNodeMouseDown = useCallback((id: string) => (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
const node = nodesRef.current.find((n) => n.id === id);
if (!node) return;
const net = netsRef.current.find((nn) => nn.id === node.netId);
if (!net) return;
const w = toWorld(e.clientX, e.clientY);
setDrag({ type: 'node', id, offX: w.x - (net.x + node.x), offY: w.y - (net.y + node.y) });
}, [toWorld]);
const onNetMouseDown = useCallback((id: string) => (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
const net = netsRef.current.find((n) => n.id === id);
if (!net) return;
const w = toWorld(e.clientX, e.clientY);
setDrag({ type: 'net', id, offX: w.x - net.x, offY: w.y - net.y });
}, [toWorld]);
const onPortMouseDown = useCallback((id: string) => (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
const node = nodesRef.current.find((n) => n.id === id);
if (!node) return;
const parent = netsRef.current.find((n) => n.id === node.netId);
if (!parent) return;
const fx = parent.x + node.x + 140;
const fy = parent.y + node.y + 22;
const w = toWorld(e.clientX, e.clientY);
setEdgeDraw({ fromId: id, fromX: fx, fromY: fy, toX: w.x, toY: w.y, hoverTarget: null });
}, [toWorld]);
const onNetResizeMouseDown = useCallback((id: string, handle: ResizeHandle) => (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
const net = netsRef.current.find((n) => n.id === id);
if (!net) return;
setDrag({ type: 'resize', id, handle, startX: e.clientX, startY: e.clientY, start: { ...net } });
}, []);
/* ── Global mousemove / mouseup ───────────────────────── */
useEffect(() => {
const onMove = (e: MouseEvent) => {
const pd = paletteDragRef.current;
if (pd) {
setPaletteDrag({ ...pd, clientX: e.clientX, clientY: e.clientY });
return;
}
const ed = edgeDrawRef.current;
if (ed) {
const o = canvasOriginRef.current();
const p = panRef.current;
const z = zoomRef.current;
const wx = (e.clientX - o.x - p.x) / z;
const wy = (e.clientY - o.y - p.y) / z;
const hover = nodesRef.current.find((n) => {
if (n.id === ed.fromId) return false;
const parent = netsRef.current.find((nn) => nn.id === n.netId);
if (!parent) return false;
const ax = parent.x + n.x;
const ay = parent.y + n.y;
return wx >= ax - 12 && wx <= ax + 140 && wy >= ay && wy <= ay + 80;
});
setEdgeDraw({ ...ed, toX: wx, toY: wy, hoverTarget: hover?.id ?? null });
return;
}
const d = dragRef.current;
if (!d) return;
if (d.type === 'pan') {
// Mutate panRef directly and schedule a DOM write. setPan is
// deferred to mouseup so we avoid a full React re-render per
// mousemove. Other reads of panRef (toWorld, context menu, etc.)
// see the live value immediately.
panRef.current = {
x: d.panX + (e.clientX - d.startX),
y: d.panY + (e.clientY - d.startY),
};
writeTransform();
return;
}
const w = (() => {
const o = canvasOriginRef.current();
const p = panRef.current;
const z = zoomRef.current;
return { x: (e.clientX - o.x - p.x) / z, y: (e.clientY - o.y - p.y) / z };
})();
if (d.type === 'net') {
setNets((prev) => prev.map((n) => n.id === d.id ? { ...n, x: Math.round(w.x - d.offX), y: Math.round(w.y - d.offY) } : n));
return;
}
if (d.type === 'node') {
const node = nodesRef.current.find((n) => n.id === d.id);
if (!node) return;
const isObserved = node.kind === 'observed';
const isPinned = node.kind === 'decky' && !!node.decky_config?.forwards_l3;
const targetNet = !isObserved && !isPinned ? netsRef.current.find((net) => {
if (net.id === node.netId) return false;
return w.x >= net.x && w.x <= net.x + net.w && w.y >= net.y && w.y <= net.y + net.h;
}) : undefined;
setDropTargetId(targetNet?.id ?? null);
const parent = netsRef.current.find((n) => n.id === node.netId);
if (!parent) return;
const maxX = Math.max(8, parent.w - 148);
const maxY = Math.max(28, parent.h - 88);
const nx = Math.min(maxX, Math.max(8, Math.round(w.x - d.offX - parent.x)));
const ny = Math.min(maxY, Math.max(28, Math.round(w.y - d.offY - parent.y)));
setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, x: nx, y: ny } : n));
return;
}
if (d.type === 'resize') {
const z = zoomRef.current;
const dx = (e.clientX - d.startX) / z;
const dy = (e.clientY - d.startY) / z;
setNets((prev) => prev.map((n) => {
if (n.id !== d.id) return n;
let { x, y, w: width, h: height } = d.start;
const MIN_W = 220, MIN_H = 140;
if (d.handle.includes('e')) width = Math.max(MIN_W, d.start.w + dx);
if (d.handle.includes('s')) height = Math.max(MIN_H, d.start.h + dy);
if (d.handle.includes('w')) {
width = Math.max(MIN_W, d.start.w - dx);
x = d.start.x + (d.start.w - width);
}
if (d.handle.includes('n')) {
height = Math.max(MIN_H, d.start.h - dy);
y = d.start.y + (d.start.h - height);
}
return { ...n, x, y, w: width, h: height };
}));
return;
}
};
const onUp = (e: MouseEvent) => {
const pd = paletteDragRef.current;
if (pd) {
setPaletteDrag(null);
const o = canvasOriginRef.current();
const p = panRef.current;
const z = zoomRef.current;
const wx = (e.clientX - o.x - p.x) / z;
const wy = (e.clientY - o.y - p.y) / z;
const rect = canvasRef.current?.getBoundingClientRect();
const inside = rect
? e.clientX >= rect.left && e.clientX <= rect.right
&& e.clientY >= rect.top && e.clientY <= rect.bottom
: false;
if (!inside) return;
const overNet = netsRef.current.find(
(n) => wx >= n.x && wx <= n.x + n.w && wy >= n.y && wy <= n.y + n.h,
);
const overNode = nodesRef.current.find((n) => {
const parent = netsRef.current.find((nn) => nn.id === n.netId);
if (!parent) return false;
const ax = parent.x + n.x;
const ay = parent.y + n.y;
return wx >= ax && wx <= ax + 140 && wy >= ay && wy <= ay + 80;
});
onPaletteDrop?.(pd, { x: wx, y: wy }, overNet?.id ?? null, overNode?.id ?? null);
return;
}
const ed = edgeDrawRef.current;
if (ed) {
if (ed.hoverTarget && ed.hoverTarget !== ed.fromId) {
const target = nodesRef.current.find((n) => n.id === ed.hoverTarget);
if (target && target.kind !== 'observed') {
onAddEdge?.(ed.fromId, ed.hoverTarget);
}
}
setEdgeDraw(null);
return;
}
const d = dragRef.current;
if (!d) return;
if (d.type === 'node') {
const node = nodesRef.current.find((n) => n.id === d.id);
const target = dropTargetId;
if (node && node.kind === 'decky' && target && target !== node.netId) {
const parentOld = netsRef.current.find((nn) => nn.id === node.netId);
const parentNew = netsRef.current.find((nn) => nn.id === target);
if (parentOld && parentNew) {
const absX = parentOld.x + node.x;
const absY = parentOld.y + node.y;
const relX = Math.max(8, absX - parentNew.x);
const relY = Math.max(28, absY - parentNew.y);
const fromNetId = node.netId;
setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, netId: target, x: relX, y: relY } : n));
onReparent?.(d.id, fromNetId, target);
}
}
/* Intra-net moves and net/resize drags are cosmetic — never persisted. */
}
if (d.type === 'pan') {
// Commit the drag-accumulated pan (written only to panRef during
// the drag) back to React state so anything reading via props
// (status bar, auto-layout, persistence) sees the final value.
setPan(panRef.current);
}
setDropTargetId(null);
setDrag(null);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [setNets, setNodes, dropTargetId, onPaletteDrop, onReparent, onAddEdge, canvasRef]);
const resetPan = useCallback(() => {
panRef.current = { x: 0, y: 0 };
zoomRef.current = 1;
setPan({ x: 0, y: 0 });
setZoom(1);
writeTransform();
}, [writeTransform]);
/* Wheel zoom anchored at cursor — attached as a native non-passive
* listener so preventDefault() actually stops the page from scrolling
* while zooming the canvas. */
useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const o = canvasOriginRef.current();
const p = panRef.current;
const z = zoomRef.current;
const factor = Math.exp(-e.deltaY * 0.0015);
const nz = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z * factor));
if (nz === z) return;
const mx = e.clientX - o.x;
const my = e.clientY - o.y;
const wx = (mx - p.x) / z;
const wy = (my - p.y) / z;
const np = { x: mx - wx * nz, y: my - wy * nz };
panRef.current = np;
zoomRef.current = nz;
setZoom(nz);
setPan(np);
writeTransform();
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [canvasRef, writeTransform]);
const zoomBy = useCallback((mult: number) => {
const z = zoomRef.current;
const nz = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z * mult));
if (nz === z) return;
const rect = canvasRef.current?.getBoundingClientRect();
const cx = (rect?.width ?? 0) / 2;
const cy = (rect?.height ?? 0) / 2;
const p = panRef.current;
const wx = (cx - p.x) / z;
const wy = (cy - p.y) / z;
const np = { x: cx - wx * nz, y: cy - wy * nz };
panRef.current = np;
zoomRef.current = nz;
setZoom(nz);
setPan(np);
writeTransform();
}, [canvasRef, writeTransform]);
return {
pan,
zoom,
dropTargetId,
dragging: drag !== null,
edgeDraw,
paletteDrag,
startPaletteDrag,
onCanvasMouseDown,
onNodeMouseDown,
onNetMouseDown,
onNetResizeMouseDown,
onPortMouseDown,
resetPan,
zoomBy,
panLayerRef,
gridPatternRef,
};
}

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef } from 'react';
import type { Net, MazeNode } from './types';
/** Per-topology canvas layout persisted to localStorage. Keyed by
* topology id so two topologies don't share positions. Stored keys
* for missing LAN/decky ids are pruned on save (self-heal). */
interface NetLayout { x: number; y: number; w: number; h: number }
interface NodeLayout { x: number; y: number }
export interface LayoutSnapshot {
nets: Record<string, NetLayout>;
nodes: Record<string, NodeLayout>;
}
const EMPTY: LayoutSnapshot = { nets: {}, nodes: {} };
const SAVE_DEBOUNCE_MS = 300;
function storageKey(topologyId: string): string {
return `mazenet.layout.${topologyId}`;
}
export function loadLayout(topologyId: string | null): LayoutSnapshot {
if (!topologyId) return EMPTY;
try {
const raw = window.localStorage.getItem(storageKey(topologyId));
if (!raw) return EMPTY;
const parsed = JSON.parse(raw) as Partial<LayoutSnapshot>;
return {
nets: parsed.nets ?? {},
nodes: parsed.nodes ?? {},
};
} catch {
return EMPTY;
}
}
function saveLayout(topologyId: string, snap: LayoutSnapshot): void {
try {
window.localStorage.setItem(storageKey(topologyId), JSON.stringify(snap));
} catch {
/* quota exhausted or private mode — layout reverts to grid. */
}
}
/** Apply stored positions on top of grid-laid-out entities. Entities
* without a stored entry keep their grid position. */
export function applyLayout(
nets: Net[],
nodes: MazeNode[],
layout: LayoutSnapshot,
): { nets: Net[]; nodes: MazeNode[] } {
const adjustedNets = nets.map((n) => {
const saved = layout.nets[n.id];
return saved ? { ...n, x: saved.x, y: saved.y, w: saved.w, h: saved.h } : n;
});
const adjustedNodes = nodes.map((n) => {
const saved = layout.nodes[n.id];
return saved ? { ...n, x: saved.x, y: saved.y } : n;
});
return { nets: adjustedNets, nodes: adjustedNodes };
}
/** Debounced writer — every nets/nodes change is captured and flushed
* to localStorage after a short idle window. Also prunes entries for
* LANs / deckies that no longer exist in the current topology. */
export function useLayoutPersistor(
topologyId: string | null,
nets: Net[],
nodes: MazeNode[],
): void {
const timerRef = useRef<number | null>(null);
useEffect(() => {
if (!topologyId) return;
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
const snap: LayoutSnapshot = { nets: {}, nodes: {} };
for (const n of nets) {
if (n.kind === 'internet') continue;
snap.nets[n.id] = { x: n.x, y: n.y, w: n.w, h: n.h };
}
for (const n of nodes) {
snap.nodes[n.id] = { x: n.x, y: n.y };
}
saveLayout(topologyId, snap);
timerRef.current = null;
}, SAVE_DEBOUNCE_MS);
return () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [topologyId, nets, nodes]);
}
/** Clear the stored layout for a topology — call after delete so stale
* entries don't linger forever. */
export function clearLayout(topologyId: string): void {
try {
window.localStorage.removeItem(storageKey(topologyId));
} catch {
/* ignore */
}
}
/** Hook form for consumers that prefer a stable callback. */
export function useClearLayout(): (topologyId: string) => void {
return useCallback((id: string) => clearLayout(id), []);
}

View File

@@ -0,0 +1,220 @@
/**
* Status-aware topology editor — wraps {@link useMazeApi} so the MazeNET
* editor can call one set of primitives regardless of whether the
* topology is ``pending`` (direct CRUD) or ``active|degraded`` (mutation
* queue via :func:`enqueueMutation`).
*
* Primitives return a tagged {@link PrimitiveResult}:
* ``{ kind: 'applied', data }`` — backend wrote synchronously; the
* caller may update local state.
* ``{ kind: 'enqueued', mutationId }`` — mutator will apply async;
* caller must NOT touch local state,
* SSE ``mutation.applied`` drives refetch.
*
* Name arguments (``deckyName``, ``lanName``) are required on every
* primitive because mutation ops are name-keyed while direct CRUD is
* uuid-keyed. Callers plumb both.
*/
import { useMemo } from 'react';
import type {
CreateDeckyBody,
CreateLanBody,
DeckyRow,
EdgeRow,
LANRow,
MazeApi,
} from './useMazeApi';
export interface UseTopologyEditorOptions {
api: MazeApi;
/** Current topology status from :func:`getTopology`. */
topoStatus: string;
/** Last-known topology version for optimistic concurrency. */
topoVersion: number;
}
export type PrimitiveResult<T> =
| { kind: 'applied'; data: T }
| { kind: 'enqueued'; mutationId: string };
export interface UseTopologyEditor {
createLan(topologyId: string, body: CreateLanBody): Promise<PrimitiveResult<LANRow>>;
updateLan(
topologyId: string,
lanId: string,
lanName: string,
patch: Partial<LANRow>,
): Promise<PrimitiveResult<LANRow>>;
deleteLan(
topologyId: string,
lanId: string,
lanName: string,
): Promise<PrimitiveResult<void>>;
createDecky(topologyId: string, body: CreateDeckyBody): Promise<PrimitiveResult<DeckyRow>>;
/** Composite: create a decky and attach it to its home LAN. On pending
* this is two CRUD calls; on active it's one ``add_decky`` enqueue.
* Callers should prefer this over ``createDecky`` + ``attachEdge`` so
* the active path doesn't 409 on the CRUD half. */
addDeckyToLan(
topologyId: string,
body: CreateDeckyBody,
lanId: string,
lanName: string,
opts?: { is_bridge?: boolean; forwards_l3?: boolean },
): Promise<PrimitiveResult<DeckyRow>>;
updateDecky(
topologyId: string,
uuid: string,
deckyName: string,
patch: Partial<DeckyRow>,
): Promise<PrimitiveResult<DeckyRow>>;
deleteDecky(
topologyId: string,
uuid: string,
deckyName: string,
): Promise<PrimitiveResult<void>>;
attachEdge(
topologyId: string,
body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean },
deckyName: string,
lanName: string,
): Promise<PrimitiveResult<EdgeRow>>;
detachEdge(
topologyId: string,
edgeId: string,
deckyName: string,
lanName: string,
): Promise<PrimitiveResult<void>>;
}
export function useTopologyEditor(
opts: UseTopologyEditorOptions,
): UseTopologyEditor {
const { api, topoStatus, topoVersion } = opts;
const live = topoStatus === 'active' || topoStatus === 'degraded';
return useMemo<UseTopologyEditor>(() => ({
// ── LAN ────────────────────────────────────────────────────────────
async createLan(topologyId, body) {
if (!live) {
const data = await api.createLan(topologyId, body);
return { kind: 'applied', data };
}
// add_lan payload: {name, subnet?, is_dmz?, x?, y?}
const payload: Record<string, unknown> = { name: body.name };
if (body.subnet !== undefined) payload.subnet = body.subnet;
if (body.is_dmz !== undefined) payload.is_dmz = body.is_dmz;
if (body.x !== undefined) payload.x = body.x;
if (body.y !== undefined) payload.y = body.y;
const res = await api.enqueueMutation(topologyId, 'add_lan', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
async updateLan(topologyId, lanId, lanName, patch) {
if (!live) {
const data = await api.updateLan(topologyId, lanId, patch);
return { kind: 'applied', data };
}
const payload: Record<string, unknown> = { name: lanName };
const patchFields: Record<string, unknown> = {};
for (const [k, v] of Object.entries(patch)) {
if (k === 'x' || k === 'y') payload[k] = v;
else patchFields[k] = v;
}
if (Object.keys(patchFields).length > 0) payload.patch = patchFields;
const res = await api.enqueueMutation(topologyId, 'update_lan', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
async deleteLan(topologyId, lanId, lanName) {
if (!live) {
await api.deleteLan(topologyId, lanId);
return { kind: 'applied', data: undefined };
}
const res = await api.enqueueMutation(
topologyId, 'remove_lan', { name: lanName }, topoVersion,
);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
// ── Decky ──────────────────────────────────────────────────────────
async createDecky(topologyId, body) {
// Bare create — only valid on pending. On active callers should use
// addDeckyToLan() instead; the backend guard will 409 here.
const data = await api.createDecky(topologyId, body);
return { kind: 'applied', data };
},
async addDeckyToLan(topologyId, body, lanId, lanName, opts) {
if (!live) {
const data = await api.createDecky(topologyId, body);
await api.attachEdge(topologyId, {
decky_uuid: data.uuid,
lan_id: lanId,
is_bridge: opts?.is_bridge,
forwards_l3: opts?.forwards_l3,
});
return { kind: 'applied', data };
}
const payload: Record<string, unknown> = {
name: body.name,
lan: lanName,
services: body.services,
};
const cfg = body.decky_config ?? {};
if (cfg.archetype !== undefined) payload.archetype = cfg.archetype;
const fwd = opts?.forwards_l3 ?? cfg.forwards_l3;
if (fwd !== undefined) payload.forwards_l3 = fwd;
if (body.x !== undefined) payload.x = body.x;
if (body.y !== undefined) payload.y = body.y;
const res = await api.enqueueMutation(topologyId, 'add_decky', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
async updateDecky(topologyId, uuid, deckyName, patch) {
if (!live) {
const data = await api.updateDecky(topologyId, uuid, patch);
return { kind: 'applied', data };
}
const payload: Record<string, unknown> = { decky: deckyName };
const patchFields: Record<string, unknown> = {};
for (const [k, v] of Object.entries(patch)) {
if (k === 'services' || k === 'x' || k === 'y') payload[k] = v;
else patchFields[k] = v;
}
if (Object.keys(patchFields).length > 0) payload.patch = patchFields;
const res = await api.enqueueMutation(topologyId, 'update_decky', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
async deleteDecky(topologyId, uuid, deckyName) {
if (!live) {
await api.deleteDecky(topologyId, uuid);
return { kind: 'applied', data: undefined };
}
const res = await api.enqueueMutation(
topologyId, 'remove_decky', { decky: deckyName }, topoVersion,
);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
// ── Edges ──────────────────────────────────────────────────────────
async attachEdge(topologyId, body, deckyName, lanName) {
if (!live) {
const data = await api.attachEdge(topologyId, body);
return { kind: 'applied', data };
}
const payload: Record<string, unknown> = { decky: deckyName, lan: lanName };
if (body.forwards_l3 !== undefined) payload.forwards_l3 = body.forwards_l3;
const res = await api.enqueueMutation(topologyId, 'attach_decky', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
async detachEdge(topologyId, edgeId, deckyName, lanName) {
if (!live) {
await api.detachEdge(topologyId, edgeId);
return { kind: 'applied', data: undefined };
}
const res = await api.enqueueMutation(
topologyId, 'detach_decky', { decky: deckyName, lan: lanName }, topoVersion,
);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
}), [api, live, topoVersion]);
}

View File

@@ -0,0 +1,107 @@
/**
* Topology event stream — opens an SSE connection to
* `/topologies/{id}/events` and dispatches typed events to the caller.
*
* Mirrors the reconnect shape used by the dashboard's `/stream` consumer:
* on any error we close the current EventSource and retry after 3s. The
* hook is inert until `topologyId` is non-empty and `enabled` is true —
* typical usage is to gate on `topoStatus === 'active' || 'degraded'` so
* pending topologies don't open a useless channel.
*/
import { useEffect, useRef } from 'react';
export type TopologyStreamEventName =
| 'snapshot'
| 'mutation.enqueued'
| 'mutation.applying'
| 'mutation.applied'
| 'mutation.failed'
| 'status';
export interface TopologyStreamEvent {
name: TopologyStreamEventName | string;
topic?: string;
type?: string;
ts?: string;
payload: Record<string, unknown>;
}
export interface UseTopologyStreamOptions {
topologyId: string | null;
enabled: boolean;
onEvent: (event: TopologyStreamEvent) => void;
onError?: () => void;
}
const NAMED_EVENTS: TopologyStreamEventName[] = [
'snapshot',
'mutation.enqueued',
'mutation.applying',
'mutation.applied',
'mutation.failed',
'status',
];
export function useTopologyStream({
topologyId,
enabled,
onEvent,
onError,
}: UseTopologyStreamOptions): void {
const esRef = useRef<EventSource | null>(null);
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep the latest callbacks in refs so reconnect logic doesn't tear
// down and rebuild the connection every time the consumer rerenders.
const onEventRef = useRef(onEvent);
const onErrorRef = useRef(onError);
useEffect(() => { onEventRef.current = onEvent; }, [onEvent]);
useEffect(() => { onErrorRef.current = onError; }, [onError]);
useEffect(() => {
if (!enabled || !topologyId) return;
const connect = () => {
if (esRef.current) esRef.current.close();
const token = localStorage.getItem('token') ?? '';
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
const url = `${baseUrl}/topologies/${topologyId}/events?token=${encodeURIComponent(token)}`;
const es = new EventSource(url);
esRef.current = es;
const dispatch = (name: string) => (event: MessageEvent) => {
try {
const parsed = JSON.parse(event.data) as Partial<TopologyStreamEvent>;
onEventRef.current({
name,
topic: parsed.topic,
type: parsed.type,
ts: parsed.ts,
payload: (parsed.payload ?? {}) as Record<string, unknown>,
});
} catch (err) {
console.error('useTopologyStream: parse failed', err);
}
};
for (const name of NAMED_EVENTS) {
es.addEventListener(name, dispatch(name) as EventListener);
}
es.onerror = () => {
es.close();
esRef.current = null;
onErrorRef.current?.();
reconnectRef.current = setTimeout(connect, 3000);
};
};
connect();
return () => {
if (reconnectRef.current) clearTimeout(reconnectRef.current);
if (esRef.current) esRef.current.close();
esRef.current = null;
};
}, [topologyId, enabled]);
}