282 lines
12 KiB
TypeScript
282 lines
12 KiB
TypeScript
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;
|