diff --git a/decnet_web/src/components/MazeNET/useMazeContextMenu.test.ts b/decnet_web/src/components/MazeNET/useMazeContextMenu.test.ts new file mode 100644 index 00000000..416051da --- /dev/null +++ b/decnet_web/src/components/MazeNET/useMazeContextMenu.test.ts @@ -0,0 +1,132 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useMazeContextMenu } from './useMazeContextMenu'; +import type { Net, MazeNode } from './types'; +import type { UseTopologyEditor } from './useTopologyEditor'; + +const noop = () => {}; +const noopAsync = async () => {}; + +const stubEditor = (): UseTopologyEditor => ({ + inFlight: 0, + addLan: vi.fn(), + updateLanRow: vi.fn(), + deleteLan: vi.fn(), + addDeckyToLan: vi.fn(), + updateDecky: vi.fn(), + deleteDecky: vi.fn(), + attachEdge: vi.fn(), + detachEdge: vi.fn(), +} as unknown as UseTopologyEditor); + +const fakeMouse = (overrides: Partial = {}): React.MouseEvent => ({ + clientX: 100, + clientY: 200, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...overrides, +} as unknown as React.MouseEvent); + +const subnet: Net = { + id: 'lan-1', name: 'lan-corp', label: 'CORP', + cidr: '10.0.0.0/24', kind: 'subnet', x: 0, y: 0, w: 300, h: 240, +}; +const internet: Net = { + id: 'lan-www', name: 'internet', label: 'INTERNET', + cidr: '0.0.0.0/0', kind: 'internet', x: 0, y: 0, w: 300, h: 240, +}; +const decky: MazeNode = { + kind: 'decky', id: 'd1', name: 'decoy-01', netId: 'lan-1', + archetype: 'workstation', services: ['ssh'], status: 'idle', x: 0, y: 0, +}; +const observed: MazeNode = { + kind: 'observed', id: 'obs-1', label: '1.2.3.4', netId: 'lan-www', + x: 0, y: 0, +}; + +const baseArgs = () => ({ + nets: [subnet, internet], + nodes: [decky, observed], + services: [ + { slug: 'http', name: 'HTTP', proto: 'tcp', port: 80, icon: 'globe', risk: 'medium' as const }, + ], + archetypes: [ + { slug: 'workstation', name: 'Workstation', services: ['ssh'], icon: 'monitor' }, + ], + topologyId: 'topo-1', + setSelection: vi.fn(), + setNodes: vi.fn(), + canvasRef: { current: null as HTMLDivElement | null } as React.RefObject, + pan: { x: 0, y: 0 }, + editor: stubEditor(), + flashErr: vi.fn(), + onPaletteDrop: vi.fn(), + removeNet: vi.fn(), + removeNode: vi.fn(), + removeEdge: vi.fn(), + duplicateNode: vi.fn(), + addServiceToNode: vi.fn(), +}); + +describe('useMazeContextMenu', () => { + it('starts with no menu open and clears on closeMenu', () => { + const { result } = renderHook(() => useMazeContextMenu(baseArgs())); + expect(result.current.ctxMenu).toBeNull(); + + act(() => result.current.onNodeContextMenu('d1')(fakeMouse())); + expect(result.current.ctxMenu).not.toBeNull(); + expect(result.current.ctxMenu?.x).toBe(100); + expect(result.current.ctxMenu?.items.length).toBeGreaterThan(0); + + act(() => result.current.closeMenu()); + expect(result.current.ctxMenu).toBeNull(); + }); + + it('node context menu offers add-service / mutate / duplicate / delete', () => { + const { result } = renderHook(() => useMazeContextMenu(baseArgs())); + act(() => result.current.onNodeContextMenu('d1')(fakeMouse())); + const labels = result.current.ctxMenu?.items.map((i) => i.label); + expect(labels).toContain('Add service…'); + expect(labels).toContain('Force mutate'); + expect(labels).toContain('Duplicate decky'); + expect(labels).toContain('Delete decky'); + }); + + it('observed entities lock duplicate + delete', () => { + const { result } = renderHook(() => useMazeContextMenu(baseArgs())); + act(() => result.current.onNodeContextMenu('obs-1')(fakeMouse())); + const dup = result.current.ctxMenu?.items.find((i) => i.label === 'Duplicate decky'); + const del = result.current.ctxMenu?.items.find((i) => i.label === 'Delete decky'); + expect(dup?.disabled).toBe(true); + expect(del?.disabled).toBe(true); + }); + + it('internet network cannot be deleted', () => { + const { result } = renderHook(() => useMazeContextMenu(baseArgs())); + act(() => result.current.onNetContextMenu('lan-www')(fakeMouse())); + const del = result.current.ctxMenu?.items.find((i) => + typeof i.label === 'string' && i.label.startsWith('Delete'), + ); + expect(del?.disabled).toBe(true); + }); + + it('canvas context menu provides Add subnet / Add DMZ', () => { + const { result } = renderHook(() => useMazeContextMenu(baseArgs())); + act(() => result.current.onCanvasContextMenu(fakeMouse())); + const labels = result.current.ctxMenu?.items.map((i) => i.label); + expect(labels).toEqual(['Add subnet here', 'Add DMZ here']); + }); + + it('edge context menu invokes removeEdge on click', () => { + const args = baseArgs(); + const { result } = renderHook(() => useMazeContextMenu(args)); + act(() => result.current.onEdgeContextMenu('e1')(fakeMouse())); + const rm = result.current.ctxMenu?.items[0]; + expect(rm?.label).toBe('Remove edge'); + rm?.onClick?.(); + expect(args.removeEdge).toHaveBeenCalledWith('e1'); + }); +}); diff --git a/decnet_web/src/components/MazeNET/useMazeContextMenu.tsx b/decnet_web/src/components/MazeNET/useMazeContextMenu.tsx new file mode 100644 index 00000000..5d104574 --- /dev/null +++ b/decnet_web/src/components/MazeNET/useMazeContextMenu.tsx @@ -0,0 +1,215 @@ +import React, { useState } from 'react'; +import { + Copy, Eye, GitMerge, Plus, Server, ShieldAlert, Trash2, Zap, +} from '../../icons'; +import type { Selection } from './Inspector'; +import type { MenuItem } from './ContextMenu'; +import type { Net, MazeNode, DeckyNode } from './types'; +import type { Archetype, ServiceDef } from './data'; +import type { UseTopologyEditor } from './useTopologyEditor'; +import type { PaletteDrag } from './useMazeInteraction'; + +type CtxMenuState = { x: number; y: number; items: MenuItem[] } | null; + +interface PanLike { x: number; y: number } + +interface Args { + nets: Net[]; + nodes: MazeNode[]; + services: ServiceDef[]; + archetypes: Archetype[]; + topologyId: string; + setSelection: (s: Selection) => void; + setNodes: React.Dispatch>; + canvasRef: React.RefObject; + pan: PanLike; + editor: UseTopologyEditor; + flashErr: (err: unknown, fallback: string) => void; + onPaletteDrop: ( + drag: PaletteDrag, + world: { x: number; y: number }, + overNetId: string | null, + overNodeId: string | null, + ) => void | Promise; + removeNet: (id: string) => void | Promise; + removeNode: (id: string) => void | Promise; + removeEdge: (id: string) => void | Promise; + duplicateNode: (id: string) => void | Promise; + addServiceToNode: (id: string, slug: string) => void | Promise; +} + +const tempIdSuffix = (): string => + Math.random().toString(36).slice(2, 6); + +export interface UseMazeContextMenuResult { + ctxMenu: CtxMenuState; + closeMenu: () => 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; +} + +/** Pure UI logic for the canvas context menu. Owns the menu's + * open/close state and exposes one builder per surface (node / + * net / edge / canvas). The actual operations come in as + * callbacks so the hook is testable in isolation and the + * page shell can keep its own optimistic-patch logic. */ +export function useMazeContextMenu(args: Args): UseMazeContextMenuResult { + const { + nets, nodes, services, archetypes, topologyId, + setSelection, setNodes, canvasRef, pan, + editor, flashErr, onPaletteDrop, + removeNet, removeNode, removeEdge, duplicateNode, addServiceToNode, + } = args; + + const [ctxMenu, setCtxMenu] = useState(null); + const closeMenu = () => setCtxMenu(null); + + // 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(); + 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: , disabled: isObs, + title: isObs ? 'observed entity — services fixed' : undefined, + submenu: serviceSubmenu }, + { label: 'Force mutate', icon: , disabled: isObs, + onClick: () => forceMutate(id) }, + { label: 'Duplicate decky', icon: , disabled: locked, + title: lockedTitle, onClick: () => duplicateNode(id) }, + { separator: true, label: '' }, + { label: 'Delete decky', icon: , 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: , + onClick: async () => { + const name = `decky-${tempIdSuffix()}`; + 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: , submenu: archetypeSubmenu }, + { label: 'Inspect', icon: , onClick: () => setSelection({ type: 'net', id }) }, + { separator: true, label: '' }, + { label: net.kind === 'dmz' ? 'Delete DMZ' : 'Delete network', + icon: , 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: , 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: , + onClick: () => { + const rect = canvasRef.current?.getBoundingClientRect(); + const wx = e.clientX - (rect?.left ?? 0) - pan.x; + const wy = e.clientY - (rect?.top ?? 0) - pan.y; + void 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: , + onClick: () => { + const rect = canvasRef.current?.getBoundingClientRect(); + const wx = e.clientX - (rect?.left ?? 0) - pan.x; + const wy = e.clientY - (rect?.top ?? 0) - pan.y; + void onPaletteDrop( + { kind: 'network-dmz', slug: 'dmz', label: 'DMZ', clientX: e.clientX, clientY: e.clientY }, + { x: wx, y: wy }, null, null, + ); + }, + }, + ], + }); + }; + + return { + ctxMenu, + closeMenu, + onNodeContextMenu, + onNetContextMenu, + onEdgeContextMenu, + onCanvasContextMenu, + }; +}