diff --git a/decnet_web/src/components/MazeNET/Canvas.tsx b/decnet_web/src/components/MazeNET/Canvas.tsx index 5d56ec28..afd8516b 100644 --- a/decnet_web/src/components/MazeNET/Canvas.tsx +++ b/decnet_web/src/components/MazeNET/Canvas.tsx @@ -9,6 +9,7 @@ interface Props { nets: Net[]; nodes: MazeNode[]; edges: Edge[]; + deployed: boolean; selection: Selection; setSelection: (s: Selection) => void; pan: { x: number; y: number }; @@ -30,7 +31,7 @@ const NODE_W = 140; const NODE_HEAD_H = 22; const Canvas = forwardRef(function Canvas( - { nets, nodes, edges, selection, setSelection, pan, dropTargetId, dragging, edgeDraw, + { nets, nodes, edges, deployed, selection, setSelection, pan, dropTargetId, dragging, edgeDraw, onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown, onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu }, ref, @@ -139,6 +140,7 @@ const Canvas = forwardRef(function Canvas( selected={net.id === selNetId} dropTarget={dropTargetId === net.id} inactive={inactive} + deployed={deployed} onSelect={(id) => setSelection({ type: 'net', id })} onHeaderMouseDown={onNetMouseDown} onResizeMouseDown={onNetResizeMouseDown} @@ -155,6 +157,7 @@ const Canvas = forwardRef(function Canvas( absX={p.x} absY={p.y} selected={n.id === selNodeId} + deployed={deployed} dragging={dragging && n.id === selNodeId} onSelect={(id) => setSelection({ type: 'node', id })} onMouseDown={onNodeMouseDown} diff --git a/decnet_web/src/components/MazeNET/ContextMenu.tsx b/decnet_web/src/components/MazeNET/ContextMenu.tsx index 017fcc0c..cf265d52 100644 --- a/decnet_web/src/components/MazeNET/ContextMenu.tsx +++ b/decnet_web/src/components/MazeNET/ContextMenu.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { ChevronRight } from 'lucide-react'; export interface MenuItem { label: string; @@ -7,6 +8,8 @@ export interface MenuItem { title?: string; danger?: boolean; separator?: boolean; + icon?: React.ReactNode; + submenu?: MenuItem[]; } interface Props { @@ -14,10 +17,12 @@ interface Props { y: number; items: MenuItem[]; onClose: () => void; + title?: string; } -const ContextMenu: React.FC = ({ x, y, items, onClose }) => { +const ContextMenu: React.FC = ({ x, y, items, onClose, title }) => { const ref = useRef(null); + const [openSub, setOpenSub] = useState(null); useEffect(() => { const onDown = (e: MouseEvent) => { @@ -32,24 +37,60 @@ const ContextMenu: React.FC = ({ x, y, items, onClose }) => { }; }, [onClose]); + const renderItem = (it: MenuItem, i: number) => { + if (it.separator) return
; + const hasSub = !!it.submenu?.length; + return ( +
setOpenSub(hasSub ? i : null)} + > + + {hasSub && openSub === i && ( +
+ {it.submenu!.map((s, j) => + s.separator ? ( +
+ ) : ( + + ), + )} +
+ )} +
+ ); + }; + return (
- {items.map((it, i) => - it.separator ? ( -
- ) : ( - - ), - )} + {title &&
{title}
} + {items.map(renderItem)}
); }; diff --git a/decnet_web/src/components/MazeNET/NetBox.tsx b/decnet_web/src/components/MazeNET/NetBox.tsx index f1f1eca1..4578950d 100644 --- a/decnet_web/src/components/MazeNET/NetBox.tsx +++ b/decnet_web/src/components/MazeNET/NetBox.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Globe, GitMerge } from 'lucide-react'; +import { Globe, GitMerge, ShieldAlert } from 'lucide-react'; import type { Net } from './types'; import type { ResizeHandle } from './useMazeInteraction'; @@ -8,6 +8,7 @@ interface Props { 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; @@ -16,17 +17,19 @@ interface Props { } const NetBox: React.FC = ({ - net, selected, dropTarget, inactive, onSelect, onHeaderMouseDown, onResizeMouseDown, onContextMenu, children, + 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' : '', ].filter(Boolean).join(' '); - const Icon = net.kind === 'internet' ? Globe : GitMerge; + const Icon = net.kind === 'internet' ? Globe : net.kind === 'dmz' ? ShieldAlert : GitMerge; const resizable = net.kind !== 'internet'; const handleBoxDown = (e: React.MouseEvent) => { diff --git a/decnet_web/src/components/MazeNET/Palette.tsx b/decnet_web/src/components/MazeNET/Palette.tsx index 4f8c9386..2e40f3cc 100644 --- a/decnet_web/src/components/MazeNET/Palette.tsx +++ b/decnet_web/src/components/MazeNET/Palette.tsx @@ -1,12 +1,13 @@ import React from 'react'; -import { GitMerge, Server, Monitor, Shield, Database, Cpu, Globe, +import { GitMerge, ShieldAlert, Server, Monitor, Shield, Database, Cpu, Globe, Terminal, Lock, Folder, HardDrive, Users, KeyRound, Radio, Zap, Wifi, Circle } from 'lucide-react'; -import { ARCHETYPES } from './data'; import type { ServiceDef, Archetype } from './data'; +import type { PaletteDrag } from './useMazeInteraction'; const ICON: Record> = { - 'git-merge': GitMerge, server: Server, monitor: Monitor, shield: Shield, + '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, @@ -19,28 +20,42 @@ function Icon({ name, size = 14, className }: { name: string; size?: number; cla interface Props { services: ServiceDef[]; - onPaletteDragStart?: (kind: 'network' | 'archetype' | 'service', slug: string, label: string) => void; + archetypes: Archetype[]; + startPaletteDrag: (d: Omit, e: React.MouseEvent) => void; } -const Palette: React.FC = ({ services, onPaletteDragStart }) => { - const start = (kind: 'network' | 'archetype' | 'service', slug: string, label: string) => - (e: React.MouseEvent) => { e.preventDefault(); onPaletteDragStart?.(kind, slug, label); }; +const Palette: React.FC = ({ services, archetypes, startPaletteDrag }) => { + const start = (d: Omit) => + (e: React.MouseEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + startPaletteDrag(d, e); + }; return (
-
+
Subnet VLAN
+
+ + DMZ + HOST +
- {ARCHETYPES.map((a: Archetype) => ( -
+ {archetypes.map((a: Archetype) => ( +
{a.name} {a.services.length} @@ -51,7 +66,11 @@ const Palette: React.FC = ({ services, onPaletteDragStart }) => {
{services.map((s) => ( -
+
= ({ services, onPaletteDragStart }) => {
- Drag empty canvas to pan. Right-click anything for a menu. Subnets - must be wired to something or they go inactive. + Drag a network onto the canvas, or an archetype onto a network, + or a service onto a decky. Right-click for menus.
diff --git a/decnet_web/src/components/MazeNET/data.ts b/decnet_web/src/components/MazeNET/data.ts index fe670a9b..af4e8ea5 100644 --- a/decnet_web/src/components/MazeNET/data.ts +++ b/decnet_web/src/components/MazeNET/data.ts @@ -1,5 +1,3 @@ -import type { Net, MazeNode, Edge } from './types'; - export interface Archetype { slug: string; name: string; @@ -43,29 +41,3 @@ export const DEFAULT_SERVICES: ServiceDef[] = [ { slug: 'coap', name: 'CoAP', port: 5683, proto: 'udp', icon: 'wifi', risk: 'low' }, ]; -/* Demo seed mirroring design-handoff/.../MazeNET.jsx INITIAL_* */ -export const DEMO_NETS: Net[] = [ - { id: 'net-internet', label: 'INTERNET', cidr: '0.0.0.0/0', kind: 'internet', x: 40, y: 40, w: 240, h: 220 }, - { id: 'net-dmz', label: 'DMZ', cidr: '10.4.2.0/24', kind: 'subnet', x: 340, y: 40, w: 340, h: 260 }, - { id: 'net-corp', label: 'CORP-LAN', cidr: '10.20.0.0/16', kind: 'subnet', x: 340, y: 340, w: 340, h: 240 }, - { id: 'net-vault', label: 'DB-VAULT', cidr: '10.88.1.0/24', kind: 'subnet', x: 740, y: 200, w: 260, h: 220 }, -]; - -export const DEMO_NODES: MazeNode[] = [ - { id: 'n-scan', kind: 'observed', netId: 'net-internet', name: 'SCANNERS', archetype: 'attacker-pool', services: ['*'], status: 'hot', x: 60, y: 80 }, - { id: 'n-edge', kind: 'decky', netId: 'net-dmz', name: 'decky-01', archetype: 'linux-server', services: ['ssh', 'http'], status: 'active', x: 20, y: 60 }, - { id: 'n-jump', kind: 'decky', netId: 'net-dmz', name: 'decky-03', archetype: 'linux-server', services: ['ssh'], status: 'hot', x: 180, y: 60 }, - { id: 'n-web', kind: 'decky', netId: 'net-dmz', name: 'decky-07', archetype: 'web-application', services: ['http'], status: 'active', x: 20, y: 160 }, - { id: 'n-ws', kind: 'decky', netId: 'net-corp', name: 'decky-02', archetype: 'windows-workstation', services: ['smb', 'rdp'], status: 'active', x: 20, y: 60 }, - { id: 'n-dc', kind: 'decky', netId: 'net-corp', name: 'decky-05', archetype: 'domain-controller', services: ['ldap', 'smb'], status: 'active', x: 180, y: 60 }, - { id: 'n-db', kind: 'decky', netId: 'net-vault', name: 'decky-12', archetype: 'database-server', services: ['mysql', 'postgres'], status: 'active', x: 50, y: 80 }, -]; - -export const DEMO_EDGES: Edge[] = [ - { id: 'e1', from: 'n-scan', to: 'n-edge', traffic: 'hot', label: 'TCP 443' }, - { id: 'e2', from: 'n-scan', to: 'n-jump', traffic: 'hot', label: 'TCP 22' }, - { id: 'e3', from: 'n-edge', to: 'n-ws', traffic: 'active', label: '' }, - { id: 'e4', from: 'n-jump', to: 'n-dc', traffic: 'hot', label: 'LAT-MOV' }, - { id: 'e5', from: 'n-dc', to: 'n-db', traffic: 'active', label: '' }, - { id: 'e6', from: 'n-web', to: 'n-db', traffic: 'active', label: 'SQL' }, -]; diff --git a/decnet_web/src/components/MazeNET/types.ts b/decnet_web/src/components/MazeNET/types.ts index 8aa33674..02facd39 100644 --- a/decnet_web/src/components/MazeNET/types.ts +++ b/decnet_web/src/components/MazeNET/types.ts @@ -47,14 +47,3 @@ export interface Edge { label?: string; } -/* ── Pending changes — mirrors Phase-3 MutationEnqueueRequest.op ── */ -export type PendingChange = - | { op: 'add_lan'; payload: { id: string; label: string; cidr: string; x: number; y: number; w: number; h: number } } - | { op: 'remove_lan'; payload: { id: string } } - | { op: 'update_lan'; payload: { id: string; patch: Partial } } - | { op: 'attach_decky'; payload: { nodeId: string; netId: string; archetype: string; name: string; x: number; y: number; services: string[] } } - | { op: 'detach_decky'; payload: { nodeId: string; netId: string } } - | { op: 'remove_decky'; payload: { nodeId: string } } - | { op: 'update_decky'; payload: { nodeId: string; patch: Partial } } - | { op: 'add_edge'; payload: { id: string; from: string; to: string } } - | { op: 'remove_edge'; payload: { id: string } }; diff --git a/decnet_web/src/components/MazeNET/useMazeApi.ts b/decnet_web/src/components/MazeNET/useMazeApi.ts index 61c4756b..45ecc6f4 100644 --- a/decnet_web/src/components/MazeNET/useMazeApi.ts +++ b/decnet_web/src/components/MazeNET/useMazeApi.ts @@ -89,8 +89,22 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology { // 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 — auto-bridge adds + // subnet edges after the original DMZ edge, but edge ordering from the + // backend is not guaranteed, so we pick DMZ explicitly for gateways. + 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(); 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); }