merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
281
decnet_web/src/components/MazeNET/Canvas.tsx
Normal file
281
decnet_web/src/components/MazeNET/Canvas.tsx
Normal 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;
|
||||
98
decnet_web/src/components/MazeNET/ContextMenu.tsx
Normal file
98
decnet_web/src/components/MazeNET/ContextMenu.tsx
Normal 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;
|
||||
303
decnet_web/src/components/MazeNET/Inspector.tsx
Normal file
303
decnet_web/src/components/MazeNET/Inspector.tsx
Normal 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;
|
||||
590
decnet_web/src/components/MazeNET/MazeNET.css
Normal file
590
decnet_web/src/components/MazeNET/MazeNET.css
Normal 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);
|
||||
}
|
||||
816
decnet_web/src/components/MazeNET/MazeNET.tsx
Normal file
816
decnet_web/src/components/MazeNET/MazeNET.tsx
Normal 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;
|
||||
91
decnet_web/src/components/MazeNET/NetBox.tsx
Normal file
91
decnet_web/src/components/MazeNET/NetBox.tsx
Normal 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);
|
||||
103
decnet_web/src/components/MazeNET/NodeCard.tsx
Normal file
103
decnet_web/src/components/MazeNET/NodeCard.tsx
Normal 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);
|
||||
117
decnet_web/src/components/MazeNET/Palette.tsx
Normal file
117
decnet_web/src/components/MazeNET/Palette.tsx
Normal 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;
|
||||
98
decnet_web/src/components/MazeNET/data.ts
Normal file
98
decnet_web/src/components/MazeNET/data.ts
Normal 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' },
|
||||
];
|
||||
|
||||
64
decnet_web/src/components/MazeNET/types.ts
Normal file
64
decnet_web/src/components/MazeNET/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
415
decnet_web/src/components/MazeNET/useMazeApi.ts
Normal file
415
decnet_web/src/components/MazeNET/useMazeApi.ts
Normal 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
424
decnet_web/src/components/MazeNET/useMazeInteraction.ts
Normal file
424
decnet_web/src/components/MazeNET/useMazeInteraction.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
111
decnet_web/src/components/MazeNET/useMazeLayoutStore.ts
Normal file
111
decnet_web/src/components/MazeNET/useMazeLayoutStore.ts
Normal 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), []);
|
||||
}
|
||||
220
decnet_web/src/components/MazeNET/useTopologyEditor.ts
Normal file
220
decnet_web/src/components/MazeNET/useTopologyEditor.ts
Normal 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]);
|
||||
}
|
||||
107
decnet_web/src/components/MazeNET/useTopologyStream.ts
Normal file
107
decnet_web/src/components/MazeNET/useTopologyStream.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user