feat(web/mazenet): port-drag edges, context menus, delete actions

This commit is contained in:
2026-04-20 19:26:49 -04:00
parent 0401cccd1d
commit 6db5842a28
8 changed files with 320 additions and 34 deletions

View File

@@ -14,18 +14,25 @@ interface Props {
pan: { x: number; y: 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;
}
const NODE_W = 140;
const NODE_HEAD_H = 22;
const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
{ nets, nodes, edges, selection, setSelection, pan, dropTargetId, dragging,
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown },
{ nets, nodes, edges, selection, setSelection, pan, dropTargetId, dragging, edgeDraw,
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu },
ref,
) {
const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
@@ -57,6 +64,9 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
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">
@@ -97,7 +107,8 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
const isSel = e.id === selEdgeId;
return (
<g key={e.id} style={{ pointerEvents: 'auto' }}
onClick={(ev) => { ev.stopPropagation(); setSelection({ type: 'edge', id: e.id }); }}>
onClick={(ev) => { ev.stopPropagation(); setSelection({ type: 'edge', id: e.id }); }}
onContextMenu={onEdgeContextMenu?.(e.id)}>
<path d={d} className={`maze-edge ${klass} 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' }} />
@@ -111,6 +122,11 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
</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">
@@ -126,6 +142,7 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
onSelect={(id) => setSelection({ type: 'net', id })}
onHeaderMouseDown={onNetMouseDown}
onResizeMouseDown={onNetResizeMouseDown}
onContextMenu={onNetContextMenu?.(net.id)}
/>
);
})}
@@ -141,6 +158,8 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
dragging={dragging && n.id === selNodeId}
onSelect={(id) => setSelection({ type: 'node', id })}
onMouseDown={onNodeMouseDown}
onPortMouseDown={onPortMouseDown}
onContextMenu={onNodeContextMenu}
/>
);
})}

View File

@@ -0,0 +1,57 @@
import React, { useEffect, useRef } from 'react';
export interface MenuItem {
label: string;
onClick?: () => void;
disabled?: boolean;
title?: string;
danger?: boolean;
separator?: boolean;
}
interface Props {
x: number;
y: number;
items: MenuItem[];
onClose: () => void;
}
const ContextMenu: React.FC<Props> = ({ x, y, items, onClose }) => {
const ref = useRef<HTMLDivElement>(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]);
return (
<div ref={ref} className="ctx-menu" style={{ left: x, top: y }}>
{items.map((it, i) =>
it.separator ? (
<div key={i} className="ctx-divider" />
) : (
<button
key={i}
type="button"
className={`ctx-item ${it.danger ? 'danger' : ''}`}
disabled={it.disabled}
title={it.title}
onClick={() => { if (!it.disabled) { it.onClick?.(); onClose(); } }}
>
{it.label}
</button>
),
)}
</div>
);
};
export default ContextMenu;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Trash2 } from 'lucide-react';
import type { Net, MazeNode, Edge, PendingChange } from './types';
export type Selection =
@@ -14,9 +15,12 @@ interface Props {
edges: Edge[];
pending: PendingChange[];
onClose?: () => void;
onDeleteNet?: (id: string) => void;
onDeleteNode?: (id: string) => void;
onDeleteEdge?: (id: string) => void;
}
const Inspector: React.FC<Props> = ({ selection, nets, nodes, edges, pending, onClose }) => {
const Inspector: React.FC<Props> = ({ selection, nets, nodes, edges, pending, onClose, onDeleteNet, onDeleteNode, onDeleteEdge }) => {
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;
@@ -40,36 +44,63 @@ const Inspector: React.FC<Props> = ({ selection, nets, nodes, edges, pending, on
{!selection && <div className="inspector-empty">SELECT AN ELEMENT</div>}
{net && (
<div className="kvs">
<div className="k">KIND</div> <div className="v">{net.kind.toUpperCase()}</div>
<div className="k">LABEL</div> <div className="v">{net.label}</div>
<div className="k">CIDR</div> <div className="v">{net.cidr}</div>
<div className="k">MEMBERS</div> <div className="v">
{nodes.filter((n) => n.netId === net.id).map((n) => n.name).join(', ') || '—'}
<>
<div className="kvs">
<div className="k">KIND</div> <div className="v">{net.kind.toUpperCase()}</div>
<div className="k">LABEL</div> <div className="v">{net.label}</div>
<div className="k">CIDR</div> <div className="v">{net.cidr}</div>
<div className="k">MEMBERS</div> <div className="v">
{nodes.filter((n) => n.netId === net.id).map((n) => n.name).join(', ') || '—'}
</div>
</div>
</div>
{net.kind !== 'internet' && onDeleteNet && (
<button type="button" className="maze-btn ghost" onClick={() => onDeleteNet(net.id)}>
<Trash2 size={12} /> DELETE NET
</button>
)}
</>
)}
{node && (
<div className="kvs">
<div className="k">KIND</div> <div className="v">{node.kind === 'observed' ? 'OBSERVED' : 'DECKY'}</div>
<div className="k">NAME</div> <div className="v">{node.name}</div>
<div className="k">ARCHETYPE</div> <div className="v">{node.archetype}</div>
<div className="k">NET</div> <div className="v">{nets.find((nn) => nn.id === node.netId)?.label ?? node.netId}</div>
<div className="k">SERVICES</div> <div className="v">{node.services.join(', ') || '—'}</div>
<div className="k">STATUS</div> <div className="v">{node.status.toUpperCase()}</div>
</div>
<>
<div className="kvs">
<div className="k">KIND</div> <div className="v">{node.kind === 'observed' ? 'OBSERVED' : 'DECKY'}</div>
<div className="k">NAME</div> <div className="v">{node.name}</div>
<div className="k">ARCHETYPE</div> <div className="v">{node.archetype}</div>
<div className="k">NET</div> <div className="v">{nets.find((nn) => nn.id === node.netId)?.label ?? node.netId}</div>
<div className="k">SERVICES</div> <div className="v">{node.services.join(', ') || '—'}</div>
<div className="k">STATUS</div> <div className="v">{node.status.toUpperCase()}</div>
</div>
{onDeleteNode && (
<button
type="button"
className="maze-btn ghost"
disabled={node.kind === 'observed'}
title={node.kind === 'observed' ? 'observed entity — not a deployed decky' : 'delete decky'}
onClick={() => node.kind === 'decky' && onDeleteNode(node.id)}
>
<Trash2 size={12} /> DELETE NODE
</button>
)}
</>
)}
{edge && (
<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>
<>
<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 ghost" onClick={() => onDeleteEdge(edge.id)}>
<Trash2 size={12} /> REMOVE EDGE
</button>
)}
</>
)}
<div>

View File

@@ -277,7 +277,8 @@
/* ── Context menu ───────────────────────────── */
.ctx-scrim { position: absolute; inset: 0; z-index: 30; }
.ctx-menu {
position: absolute; z-index: 40;
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);
@@ -289,9 +290,14 @@
margin-bottom: 4px;
}
.ctx-item {
display: flex; align-items: center; gap: 8px;
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); }

View File

@@ -6,6 +6,7 @@ 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, DEMO_NETS, DEMO_NODES, DEMO_EDGES } from './data';
import type { ServiceDef } from './data';
import type { Net, MazeNode, Edge, PendingChange } from './types';
@@ -29,9 +30,103 @@ const MazeNET: React.FC = () => {
const canvasRef = useRef<HTMLDivElement>(null);
const applyChange = useCallback((pc: PendingChange) => {
setPending((p) => [...p, pc]);
if (pc.op === 'add_edge') {
const payload = pc.payload;
setEdges((prev) => prev.some((e) => e.id === payload.id)
? prev
: [...prev, { id: payload.id, from: payload.from, to: payload.to, traffic: 'active' as const }]);
}
}, []);
const interaction = useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef });
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
const removeNet = (id: string) => {
const net = nets.find((n) => n.id === id);
if (!net || net.kind === 'internet') return;
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;
}));
applyChange({ op: 'remove_lan', payload: { id } });
setSelection(null);
};
const removeNode = (id: string) => {
const node = nodes.find((n) => n.id === id);
if (!node || node.kind === 'observed') return;
setNodes((p) => p.filter((n) => n.id !== id));
setEdges((p) => p.filter((e) => e.from !== id && e.to !== id));
applyChange({ op: 'remove_decky', payload: { nodeId: id } });
setSelection(null);
};
const removeEdge = (id: string) => {
setEdges((p) => p.filter((e) => e.id !== id));
applyChange({ op: 'remove_edge', payload: { id } });
setSelection(null);
};
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';
setCtxMenu({
x: e.clientX, y: e.clientY,
items: [
{ label: 'INSPECT', onClick: () => setSelection({ type: 'node', id }) },
{ separator: true, label: '' },
{
label: 'DELETE NODE',
danger: true,
disabled: isObs,
title: isObs ? 'observed entity — not a deployed decky' : undefined,
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 });
setCtxMenu({
x: e.clientX, y: e.clientY,
items: [
{ label: 'INSPECT', onClick: () => setSelection({ type: 'net', id }) },
{ separator: true, label: '' },
{
label: 'DELETE NET',
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', danger: true, onClick: () => removeEdge(id) },
],
});
};
/* Load service catalog from API (fall back to defaults if 401/offline). */
useEffect(() => {
let cancelled = false;
@@ -128,11 +223,19 @@ const MazeNET: React.FC = () => {
pan={interaction.pan}
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}
/>
{ctxMenu && (
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
)}
{inspectorOpen && (
<Inspector
selection={selection}
@@ -141,6 +244,9 @@ const MazeNET: React.FC = () => {
edges={edges}
pending={pending}
onClose={() => setInspectorOpen(false)}
onDeleteNet={removeNet}
onDeleteNode={removeNode}
onDeleteEdge={removeEdge}
/>
)}
</div>

View File

@@ -11,11 +11,12 @@ interface Props {
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, onSelect, onHeaderMouseDown, onResizeMouseDown, children,
net, selected, dropTarget, inactive, onSelect, onHeaderMouseDown, onResizeMouseDown, onContextMenu, children,
}) => {
const classes = [
'maze-net-box',
@@ -43,6 +44,7 @@ const NetBox: React.FC<Props> = ({
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' }}>

View File

@@ -9,9 +9,11 @@ interface Props {
dragging?: boolean;
onSelect?: (id: 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, onSelect, onMouseDown }) => {
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, onSelect, onMouseDown, onPortMouseDown, onContextMenu }) => {
const classes = [
'maze-node',
node.kind === 'observed' ? 'observed' : '',
@@ -26,7 +28,12 @@ const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, onSel
};
return (
<div className={classes} style={{ left: absX, top: absY }} onMouseDown={handleDown}>
<div
className={classes}
style={{ left: absX, top: absY }}
onMouseDown={handleDown}
onContextMenu={onContextMenu?.(node.id)}
>
<div className="mn-head">{node.name}</div>
<div className="mn-sub">{node.archetype.toUpperCase()}</div>
{node.services.length > 0 && (
@@ -40,9 +47,11 @@ const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, onSel
)}
{node.kind === 'decky' && <>
<span className="mn-port in" />
<span className="mn-port out" />
<span className="mn-port out" onMouseDown={onPortMouseDown?.(node.id)} />
</>}
{node.kind === 'observed' && <span className="mn-port out" />}
{node.kind === 'observed' && (
<span className="mn-port out" onMouseDown={onPortMouseDown?.(node.id)} />
)}
</div>
);
};

View File

@@ -19,10 +19,20 @@ interface Args {
canvasRef: React.RefObject<HTMLDivElement | null>;
}
interface EdgeDraw {
fromId: string;
fromX: number; fromY: number;
toX: number; toY: number;
hoverTarget: string | null;
}
export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef }: Args) {
const [pan, setPan] = useState({ x: 0, y: 0 });
const [drag, setDrag] = useState<Drag>(null);
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
const [edgeDraw, setEdgeDraw] = useState<EdgeDraw | null>(null);
const edgeDrawRef = useRef<EdgeDraw | null>(null);
useEffect(() => { edgeDrawRef.current = edgeDraw; }, [edgeDraw]);
/* Refs to avoid re-binding global listeners on every state change. */
const netsRef = useRef(nets);
@@ -74,6 +84,19 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
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();
@@ -86,6 +109,24 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
useEffect(() => {
const onMove = (e: MouseEvent) => {
const ed = edgeDrawRef.current;
if (ed) {
const o = canvasOriginRef.current();
const p = panRef.current;
const wx = e.clientX - o.x - p.x;
const wy = e.clientY - o.y - p.y;
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 - 8 && wx <= ax + 8 && wy >= ay + 14 && wy <= ay + 30;
});
setEdgeDraw({ ...ed, toX: wx, toY: wy, hoverTarget: hover?.id ?? null });
return;
}
const d = dragRef.current;
if (!d) return;
@@ -147,6 +188,19 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
};
const onUp = () => {
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') {
const id = `e-${ed.fromId}-${ed.hoverTarget}-${Date.now()}`;
applyChange({ op: 'add_edge', payload: { id, from: ed.fromId, to: ed.hoverTarget } });
}
}
setEdgeDraw(null);
return;
}
const d = dragRef.current;
if (!d) return;
@@ -197,10 +251,12 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
pan,
dropTargetId,
dragging: drag !== null,
edgeDraw,
onCanvasMouseDown,
onNodeMouseDown,
onNetMouseDown,
onNetResizeMouseDown,
onPortMouseDown,
resetPan,
};
}