diff --git a/decnet_web/src/components/MazeNET/Canvas.tsx b/decnet_web/src/components/MazeNET/Canvas.tsx index 025f0629..5d56ec28 100644 --- a/decnet_web/src/components/MazeNET/Canvas.tsx +++ b/decnet_web/src/components/MazeNET/Canvas.tsx @@ -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(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(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' }} >
@@ -97,7 +107,8 @@ const Canvas = forwardRef(function Canvas( const isSel = e.id === selEdgeId; return ( { ev.stopPropagation(); setSelection({ type: 'edge', id: e.id }); }}> + onClick={(ev) => { ev.stopPropagation(); setSelection({ type: 'edge', id: e.id }); }} + onContextMenu={onEdgeContextMenu?.(e.id)}> @@ -111,6 +122,11 @@ const Canvas = forwardRef(function Canvas( ); })} + {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 ; + })()}
@@ -126,6 +142,7 @@ const Canvas = forwardRef(function Canvas( onSelect={(id) => setSelection({ type: 'net', id })} onHeaderMouseDown={onNetMouseDown} onResizeMouseDown={onNetResizeMouseDown} + onContextMenu={onNetContextMenu?.(net.id)} /> ); })} @@ -141,6 +158,8 @@ const Canvas = forwardRef(function Canvas( dragging={dragging && n.id === selNodeId} onSelect={(id) => setSelection({ type: 'node', id })} onMouseDown={onNodeMouseDown} + onPortMouseDown={onPortMouseDown} + onContextMenu={onNodeContextMenu} /> ); })} diff --git a/decnet_web/src/components/MazeNET/ContextMenu.tsx b/decnet_web/src/components/MazeNET/ContextMenu.tsx new file mode 100644 index 00000000..017fcc0c --- /dev/null +++ b/decnet_web/src/components/MazeNET/ContextMenu.tsx @@ -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 = ({ x, y, items, onClose }) => { + const ref = useRef(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 ( +
+ {items.map((it, i) => + it.separator ? ( +
+ ) : ( + + ), + )} +
+ ); +}; + +export default ContextMenu; diff --git a/decnet_web/src/components/MazeNET/Inspector.tsx b/decnet_web/src/components/MazeNET/Inspector.tsx index cbe3e663..085093a6 100644 --- a/decnet_web/src/components/MazeNET/Inspector.tsx +++ b/decnet_web/src/components/MazeNET/Inspector.tsx @@ -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 = ({ selection, nets, nodes, edges, pending, onClose }) => { +const Inspector: React.FC = ({ 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 = ({ selection, nets, nodes, edges, pending, on {!selection &&
SELECT AN ELEMENT
} {net && ( -
-
KIND
{net.kind.toUpperCase()}
-
LABEL
{net.label}
-
CIDR
{net.cidr}
-
MEMBERS
- {nodes.filter((n) => n.netId === net.id).map((n) => n.name).join(', ') || '—'} + <> +
+
KIND
{net.kind.toUpperCase()}
+
LABEL
{net.label}
+
CIDR
{net.cidr}
+
MEMBERS
+ {nodes.filter((n) => n.netId === net.id).map((n) => n.name).join(', ') || '—'} +
-
+ {net.kind !== 'internet' && onDeleteNet && ( + + )} + )} {node && ( -
-
KIND
{node.kind === 'observed' ? 'OBSERVED' : 'DECKY'}
-
NAME
{node.name}
-
ARCHETYPE
{node.archetype}
-
NET
{nets.find((nn) => nn.id === node.netId)?.label ?? node.netId}
-
SERVICES
{node.services.join(', ') || '—'}
-
STATUS
{node.status.toUpperCase()}
-
+ <> +
+
KIND
{node.kind === 'observed' ? 'OBSERVED' : 'DECKY'}
+
NAME
{node.name}
+
ARCHETYPE
{node.archetype}
+
NET
{nets.find((nn) => nn.id === node.netId)?.label ?? node.netId}
+
SERVICES
{node.services.join(', ') || '—'}
+
STATUS
{node.status.toUpperCase()}
+
+ {onDeleteNode && ( + + )} + )} {edge && ( -
-
FROM
{nodes.find((n) => n.id === edge.from)?.name ?? edge.from}
-
TO
{nodes.find((n) => n.id === edge.to)?.name ?? edge.to}
-
TRAFFIC
{edge.traffic.toUpperCase()}
- {edge.label && (<> -
LABEL
{edge.label}
- )} -
+ <> +
+
FROM
{nodes.find((n) => n.id === edge.from)?.name ?? edge.from}
+
TO
{nodes.find((n) => n.id === edge.to)?.name ?? edge.to}
+
TRAFFIC
{edge.traffic.toUpperCase()}
+ {edge.label && (<> +
LABEL
{edge.label}
+ )} +
+ {onDeleteEdge && ( + + )} + )}
diff --git a/decnet_web/src/components/MazeNET/MazeNET.css b/decnet_web/src/components/MazeNET/MazeNET.css index 8dee1866..a920945b 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.css +++ b/decnet_web/src/components/MazeNET/MazeNET.css @@ -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); } diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index 919ab48b..a0138e0f 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -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(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 && ( + setCtxMenu(null)} /> + )} {inspectorOpen && ( { edges={edges} pending={pending} onClose={() => setInspectorOpen(false)} + onDeleteNet={removeNet} + onDeleteNode={removeNode} + onDeleteEdge={removeEdge} /> )}
diff --git a/decnet_web/src/components/MazeNET/NetBox.tsx b/decnet_web/src/components/MazeNET/NetBox.tsx index 8b28bafe..f1f1eca1 100644 --- a/decnet_web/src/components/MazeNET/NetBox.tsx +++ b/decnet_web/src/components/MazeNET/NetBox.tsx @@ -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 = ({ - 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 = ({ className={classes} style={{ left: net.x, top: net.y, width: net.w, height: net.h }} onMouseDown={handleBoxDown} + onContextMenu={onContextMenu} >
diff --git a/decnet_web/src/components/MazeNET/NodeCard.tsx b/decnet_web/src/components/MazeNET/NodeCard.tsx index 58fbe0d7..f24a56b1 100644 --- a/decnet_web/src/components/MazeNET/NodeCard.tsx +++ b/decnet_web/src/components/MazeNET/NodeCard.tsx @@ -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 = ({ node, absX, absY, selected, dragging, onSelect, onMouseDown }) => { +const NodeCard: React.FC = ({ 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 = ({ node, absX, absY, selected, dragging, onSel }; return ( -
+
{node.name}
{node.archetype.toUpperCase()}
{node.services.length > 0 && ( @@ -40,9 +47,11 @@ const NodeCard: React.FC = ({ node, absX, absY, selected, dragging, onSel )} {node.kind === 'decky' && <> - + } - {node.kind === 'observed' && } + {node.kind === 'observed' && ( + + )}
); }; diff --git a/decnet_web/src/components/MazeNET/useMazeInteraction.ts b/decnet_web/src/components/MazeNET/useMazeInteraction.ts index 50f6553d..3e658f16 100644 --- a/decnet_web/src/components/MazeNET/useMazeInteraction.ts +++ b/decnet_web/src/components/MazeNET/useMazeInteraction.ts @@ -19,10 +19,20 @@ interface Args { canvasRef: React.RefObject; } +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(null); const [dropTargetId, setDropTargetId] = useState(null); + const [edgeDraw, setEdgeDraw] = useState(null); + const edgeDrawRef = useRef(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, }; }