diff --git a/decnet_web/src/components/MazeNET/Inspector.tsx b/decnet_web/src/components/MazeNET/Inspector.tsx deleted file mode 100644 index 3951c5b3..00000000 --- a/decnet_web/src/components/MazeNET/Inspector.tsx +++ /dev/null @@ -1,606 +0,0 @@ -import React, { useMemo, useRef, useEffect, useState } from 'react'; -import { - ArrowLeft, ArrowRight, Crosshair, Globe, GitMerge, MousePointer2, Plus, - Server, Trash2, X, Shield, -} from '../../icons'; -import type { Net, MazeNode, Edge } from './types'; -import { DEFAULT_SERVICES } from './data'; -import ServiceConfigForm from '../ServiceConfigForm'; -import type { ApiError } from '../../utils/api'; - -export type Selection = - | { type: 'net'; id: string } - | { type: 'node'; id: string } - | { type: 'edge'; id: string } - | { type: 'service'; id: string; nodeId: string } - | null; - -interface Props { - selection: Selection; - nets: Net[]; - nodes: MazeNode[]; - edges: Edge[]; - /** Topology ID (MazeNET-only) — required for the schema-driven service - * config form to hit the per-topology REST path. Omit for fleet. */ - topologyId?: string; - topologyStatus?: string; - onClose?: () => void; - onDeleteNet?: (id: string) => void; - onDeleteNode?: (id: string) => void; - onDeleteEdge?: (id: string) => void; - onRemoveService?: (nodeId: string, slug: string) => void; - // Live (post-deploy) service mutation, hitting W3 endpoints directly. - // Distinct from onRemoveService which queues a design-time graph - // mutation. Both can coexist; the inspector picks based on - // topologyStatus (active/degraded → live, pending/anything else → - // design-time only). Wiring these props from MazeNET.tsx is the - // single switch that turns chips into live controls. - /** Trigger the schema-driven add-service flow. Synchronous: opens - * the AddServiceConfigModal at the page level (or auto-confirms if - * the service has no schema fields). Errors surface inside the modal. */ - onLiveAddService?: (nodeName: string, slug: string) => void; - onLiveRemoveService?: (nodeName: string, slug: string) => Promise; - /** Per-decky-eligible service slugs, fetched via useServiceRegistry. */ - availableServices?: string[]; - /** Toggle ``forwards_l3`` (gateway) on the selected decky. When the - * topology is active/degraded the caller is responsible for the - * destructive-recreate confirm dialog and the ``force: true`` submit - * — this prop just relays the user's intent. */ - onToggleGateway?: (nodeId: string, nextValue: boolean) => Promise; - /** Tarpit controls — only shown when topology is active/degraded and node is a deployed decky. */ - onLiveTarpitEnable?: (nodeName: string, ports: number[], delayMs: number) => Promise; - onLiveTarpitDisable?: (nodeName: string) => Promise; - onAddDecky?: (netId: string) => void; - setSelection?: (sel: Selection) => void; - pendingChanges?: number; - className?: string; -} - -const Inspector: React.FC = ({ - selection, nets, nodes, edges, topologyId, topologyStatus, onClose, - onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService, - onLiveAddService, onLiveRemoveService, availableServices = [], - onToggleGateway, - onLiveTarpitEnable, onLiveTarpitDisable, - onAddDecky, setSelection, - pendingChanges = 0, - className = '', -}) => { - const liveOpsEnabled = - !!onLiveAddService && - !!onLiveRemoveService && - (topologyStatus === 'active' || topologyStatus === 'degraded'); - const [addOpen, setAddOpen] = useState(false); - const [addSlug, setAddSlug] = useState(''); - const [busy, setBusy] = useState(null); // slug currently mutating - const [opError, setOpError] = useState(null); - - // Tarpit state — local form, fires parent callbacks - const [tarpitOpen, setTarpitOpen] = useState(false); - const [tarpitPorts, setTarpitPorts] = useState('22'); - const [tarpitDelay, setTarpitDelay] = useState(30000); - const tarpitEnabled = liveOpsEnabled && !!onLiveTarpitEnable && !!onLiveTarpitDisable; - - // Close tarpit form when selection changes - const prevNodeId = useRef(undefined); - useEffect(() => { - const nodeId = selection?.type === 'node' ? selection.id : undefined; - if (nodeId !== prevNodeId.current) { - prevNodeId.current = nodeId; - setTarpitOpen(false); - } - }, [selection]); - 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; - const serviceSel = selection?.type === 'service' ? selection : undefined; - const serviceMeta = serviceSel ? DEFAULT_SERVICES.find((s) => s.slug === serviceSel.id) : undefined; - const serviceParent = serviceSel ? nodes.find((n) => n.id === serviceSel.nodeId) : undefined; - const serviceParentNet = serviceParent ? nets.find((n) => n.id === serviceParent.netId) : undefined; - - const activeNetIds = useMemo(() => { - const s = new Set(); - edges.forEach((e) => { - const f = nodes.find((n) => n.id === e.from); - const t = nodes.find((n) => n.id === e.to); - if (f) s.add(f.netId); - if (t) s.add(t.netId); - }); - return s; - }, [edges, nodes]); - - const typeLabel = selection ? selection.type.toUpperCase() : 'IDLE'; - const isGateway = node?.kind === 'decky' && !!node.decky_config?.forwards_l3; - const isObserved = node?.kind === 'observed'; - - return ( - - ); -}; - -export default Inspector; diff --git a/decnet_web/src/components/MazeNET/Inspector/EdgeInspector.tsx b/decnet_web/src/components/MazeNET/Inspector/EdgeInspector.tsx new file mode 100644 index 00000000..16d8fdeb --- /dev/null +++ b/decnet_web/src/components/MazeNET/Inspector/EdgeInspector.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Server, Trash2 } from '../../../icons'; +import type { Edge, MazeNode } from '../types'; + +interface Props { + edge: Edge; + nodes: MazeNode[]; + onDeleteEdge?: (id: string) => void; +} + +const EdgeInspector: React.FC = ({ edge, nodes, onDeleteEdge }) => ( + <> +
+ + EDGE · {edge.id.slice(0, 8)} +
+
+
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 && ( + + )} + +); + +export default EdgeInspector; diff --git a/decnet_web/src/components/MazeNET/Inspector/Inspector.test.tsx b/decnet_web/src/components/MazeNET/Inspector/Inspector.test.tsx new file mode 100644 index 00000000..1c703a20 --- /dev/null +++ b/decnet_web/src/components/MazeNET/Inspector/Inspector.test.tsx @@ -0,0 +1,155 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Inspector from './index'; +import type { DeckyNode, Edge, Net, ObservedNode } from '../types'; + +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: DeckyNode = { + kind: 'decky', id: 'd1', name: 'decoy-01', netId: 'lan-1', + archetype: 'workstation', services: ['ssh'], status: 'idle', x: 0, y: 0, +}; +const observed: ObservedNode = { + kind: 'observed', id: 'obs-1', netId: 'lan-www', name: '1.2.3.4', + archetype: 'attacker-pool', services: ['*'], status: 'idle', x: 0, y: 0, +}; +const edge: Edge = { + id: 'e-1', from: 'obs-1', to: 'd1', traffic: 'hot', +}; + +describe('Inspector dispatcher', () => { + it('shows the empty state when nothing is selected', () => { + render( + , + ); + expect(screen.getByText(/SELECT A NODE/)).toBeInTheDocument(); + }); + + it('renders NodeInspector when a node is selected', () => { + render( + , + ); + expect(screen.getByText('decoy-01')).toBeInTheDocument(); + expect(screen.getByText('workstation')).toBeInTheDocument(); + expect(screen.getByText('CONNECTIONS')).toBeInTheDocument(); + }); + + it('renders NetInspector when a net is selected and shows the INACTIVE chip', () => { + render( + , + ); + expect(screen.getByText('CORP')).toBeInTheDocument(); + expect(screen.getByText('INACTIVE')).toBeInTheDocument(); + }); + + it('renders EdgeInspector and fires onDeleteEdge', () => { + const onDeleteEdge = vi.fn(); + render( + , + ); + expect(screen.getByText(/EDGE ·/)).toBeInTheDocument(); + fireEvent.click(screen.getByText(/CUT EDGE/)); + expect(onDeleteEdge).toHaveBeenCalledWith('e-1'); + }); + + it('renders ServiceInspector with the parent decky and remove button', () => { + const onRemoveService = vi.fn(); + render( + , + ); + expect(screen.getByText('decoy-01')).toBeInTheDocument(); + fireEvent.click(screen.getByText(/REMOVE SERVICE/)); + expect(onRemoveService).toHaveBeenCalledWith('d1', 'ssh'); + }); + + it('forbids deleting an observed entity in NodeInspector', () => { + const onDeleteNode = vi.fn(); + render( + , + ); + const btn = screen.getByText(/REMOVE FROM GRAPH/).closest('button')!; + expect(btn).toBeDisabled(); + }); + + it('forbids deleting the internet net', () => { + render( + {}} + />, + ); + expect(screen.queryByText(/REMOVE NETWORK/)).toBeNull(); + }); + + it('hides live-ops controls on a pending topology', () => { + render( + , + ); + expect(screen.queryByText(/TARPIT/)).toBeNull(); + expect(screen.queryByText(/ ADD$/)).toBeNull(); + }); + + it('shows tarpit controls when topologyStatus=active and the callbacks are present', () => { + render( + , + ); + expect(screen.getByText('TARPIT')).toBeInTheDocument(); + expect(screen.getByText('DISABLE')).toBeInTheDocument(); + }); + + it('renders pending-diff block when pendingChanges > 0', () => { + render( + , + ); + expect(screen.getByText('PENDING DIFF')).toBeInTheDocument(); + expect(screen.getByText(/\+3 graph mutation/)).toBeInTheDocument(); + }); +}); diff --git a/decnet_web/src/components/MazeNET/Inspector/NetInspector.tsx b/decnet_web/src/components/MazeNET/Inspector/NetInspector.tsx new file mode 100644 index 00000000..0c159457 --- /dev/null +++ b/decnet_web/src/components/MazeNET/Inspector/NetInspector.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { GitMerge, Globe, Plus, Trash2 } from '../../../icons'; +import type { MazeNode, Net } from '../types'; +import type { Selection } from './types'; + +interface Props { + net: Net; + nodes: MazeNode[]; + /** Set of net ids that have at least one edge — drives the + * INACTIVE chip on subnets with no live traffic. */ + activeNetIds: Set; + setSelection?: (sel: Selection) => void; + onAddDecky?: (netId: string) => void; + onDeleteNet?: (id: string) => void; +} + +const NetInspector: React.FC = ({ + net, nodes, activeNetIds, setSelection, onAddDecky, onDeleteNet, +}) => { + const members = nodes.filter((n) => n.netId === net.id); + return ( + <> +
+ {net.kind === 'internet' + ? + : } + {net.label} + {net.kind !== 'internet' && !activeNetIds.has(net.id) && ( + INACTIVE + )} +
+
+
KIND
{net.kind.toUpperCase()}
+
CIDR
{net.cidr}
+
DECKIES
+
{members.length}
+
+
+
MEMBERS
+ {members.map((n) => ( +
setSelection?.({ type: 'node', id: n.id })} + > + + {n.name} + {n.archetype} +
+ ))} + {members.length === 0 && ( +
NO MEMBERS
+ )} +
+ {net.kind !== 'internet' && onAddDecky && ( + + )} + {net.kind !== 'internet' && onDeleteNet && ( + + )} + + ); +}; + +export default NetInspector; diff --git a/decnet_web/src/components/MazeNET/Inspector/NodeInspector.tsx b/decnet_web/src/components/MazeNET/Inspector/NodeInspector.tsx new file mode 100644 index 00000000..fea8166f --- /dev/null +++ b/decnet_web/src/components/MazeNET/Inspector/NodeInspector.tsx @@ -0,0 +1,362 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + ArrowLeft, ArrowRight, Plus, Shield, Trash2, X, +} from '../../../icons'; +import type { ApiError } from '../../../utils/api'; +import type { DeckyNode, Edge, MazeNode, Net } from '../types'; + +export interface NodeInspectorProps { + node: MazeNode; + nodes: MazeNode[]; + nets: Net[]; + edges: Edge[]; + topologyStatus?: string; + /** Per-decky-eligible service slugs, fetched via useServiceRegistry. */ + availableServices?: string[]; + onDeleteNode?: (id: string) => void; + /** Trigger the schema-driven add-service flow. Synchronous: opens + * the AddServiceConfigModal at the page level (or auto-confirms if + * the service has no schema fields). Errors surface inside the modal. */ + onLiveAddService?: (nodeName: string, slug: string) => void; + onLiveRemoveService?: (nodeName: string, slug: string) => Promise; + onToggleGateway?: (nodeId: string, nextValue: boolean) => Promise; + onLiveTarpitEnable?: (nodeName: string, ports: number[], delayMs: number) => Promise; + onLiveTarpitDisable?: (nodeName: string) => Promise; + /** Selection key used to reset local form state when the user + * picks a different node. */ + selectionKey: string | undefined; +} + +const NodeInspector: React.FC = ({ + node, nodes, nets, edges, topologyStatus, availableServices = [], + onDeleteNode, onLiveAddService, onLiveRemoveService, onToggleGateway, + onLiveTarpitEnable, onLiveTarpitDisable, selectionKey, +}) => { + const liveOpsEnabled = + !!onLiveAddService && + !!onLiveRemoveService && + (topologyStatus === 'active' || topologyStatus === 'degraded'); + + const [addOpen, setAddOpen] = useState(false); + const [addSlug, setAddSlug] = useState(''); + const [busy, setBusy] = useState(null); + const [opError, setOpError] = useState(null); + const [tarpitOpen, setTarpitOpen] = useState(false); + const [tarpitPorts, setTarpitPorts] = useState('22'); + const [tarpitDelay, setTarpitDelay] = useState(30000); + const tarpitEnabled = liveOpsEnabled && !!onLiveTarpitEnable && !!onLiveTarpitDisable; + + // Close tarpit form when selection changes + const prevKey = useRef(undefined); + useEffect(() => { + if (selectionKey !== prevKey.current) { + prevKey.current = selectionKey; + setTarpitOpen(false); + } + }, [selectionKey]); + + const isObserved = node.kind === 'observed'; + const isGateway = node.kind === 'decky' + && !!(node as DeckyNode).decky_config?.forwards_l3; + + const conns = edges.filter((e) => e.from === node.id || e.to === node.id); + + return ( + <> +
+ + {node.name} + {node.archetype} +
+
+
NETWORK
+
+ {nets.find((nn) => nn.id === node.netId)?.label ?? node.netId} +
+
STATUS
+
{node.status.toUpperCase()}
+
SERVICES
+
+
+ {node.services.length === 0 && } + {node.services.map((s) => ( + + {s} + {liveOpsEnabled && !isObserved && ( + + )} + + ))} + {liveOpsEnabled && !isObserved && !addOpen && ( + + )} +
+ {liveOpsEnabled && addOpen && ( +
+ + + +
+ )} + {opError && ( +
{opError}
+ )} +
+
+
+
CONNECTIONS
+ {conns.map((e) => { + const otherId = e.from === node.id ? e.to : e.from; + const other = nodes.find((n) => n.id === otherId); + const Arrow = e.from === node.id ? ArrowRight : ArrowLeft; + return ( +
+ + {other?.name ?? '—'} + {e.traffic} +
+ ); + })} + {conns.length === 0 && ( +
NO EDGES
+ )} +
+ {onToggleGateway && !isObserved && ( + + )} + {tarpitEnabled && !isObserved && ( +
+
+ + +
+ {tarpitOpen && ( +
+
+ + setTarpitPorts(e.target.value)} + /> +
+
+ + setTarpitDelay(parseInt(e.target.value, 10))} + style={{ width: '100%' }} + /> +
+ +
+ )} +
+ )} + {onDeleteNode && ( + + )} + + ); +}; + +export default NodeInspector; diff --git a/decnet_web/src/components/MazeNET/Inspector/ServiceInspector.tsx b/decnet_web/src/components/MazeNET/Inspector/ServiceInspector.tsx new file mode 100644 index 00000000..c483c08f --- /dev/null +++ b/decnet_web/src/components/MazeNET/Inspector/ServiceInspector.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Shield, Trash2 } from '../../../icons'; +import ServiceConfigForm from '../../ServiceConfigForm'; +import { DEFAULT_SERVICES } from '../data'; +import type { MazeNode, Net } from '../types'; + +interface Props { + /** The selected service entry. The id is the service slug; nodeId + * identifies the parent decky in the topology. */ + serviceSel: { type: 'service'; id: string; nodeId: string }; + nodes: MazeNode[]; + nets: Net[]; + topologyId?: string; + topologyStatus?: string; + onRemoveService?: (nodeId: string, slug: string) => void; +} + +const ServiceInspector: React.FC = ({ + serviceSel, nodes, nets, topologyId, topologyStatus, onRemoveService, +}) => { + const serviceMeta = DEFAULT_SERVICES.find((s) => s.slug === serviceSel.id); + const serviceParent = nodes.find((n) => n.id === serviceSel.nodeId); + const serviceParentNet = serviceParent + ? nets.find((n) => n.id === serviceParent.netId) + : undefined; + + return ( + <> +
+ + + {serviceMeta?.name ?? serviceSel.id.toUpperCase()} + + {serviceMeta && ( + + {serviceMeta.risk.toUpperCase()} + + )} +
+
+
EXPOSED ON
+
{serviceParent?.name ?? '—'}
+
PROTOCOL
+
{(serviceMeta?.proto ?? '—').toUpperCase()}
+
PORT
+
{serviceMeta?.port ?? '—'}
+
SUBNET
+
{serviceParentNet?.label ?? '—'}
+
+ {topologyId && serviceParent && serviceParent.kind !== 'observed' && ( + > } | undefined) + ?.service_config?.[serviceSel.id]) ?? {} + } + /> + )} + {onRemoveService && serviceParent && serviceParent.kind !== 'observed' && ( + + )} + + ); +}; + +export default ServiceInspector; diff --git a/decnet_web/src/components/MazeNET/Inspector/index.tsx b/decnet_web/src/components/MazeNET/Inspector/index.tsx new file mode 100644 index 00000000..41189ce1 --- /dev/null +++ b/decnet_web/src/components/MazeNET/Inspector/index.tsx @@ -0,0 +1,172 @@ +import React, { useMemo } from 'react'; +import { Crosshair, MousePointer2, X } from '../../../icons'; +import type { Edge, MazeNode, Net } from '../types'; +import EdgeInspector from './EdgeInspector'; +import NetInspector from './NetInspector'; +import NodeInspector from './NodeInspector'; +import ServiceInspector from './ServiceInspector'; +import type { Selection } from './types'; + +export type { Selection }; + +interface Props { + selection: Selection; + nets: Net[]; + nodes: MazeNode[]; + edges: Edge[]; + /** Topology ID (MazeNET-only) — required for the schema-driven service + * config form to hit the per-topology REST path. Omit for fleet. */ + topologyId?: string; + topologyStatus?: string; + onClose?: () => void; + onDeleteNet?: (id: string) => void; + onDeleteNode?: (id: string) => void; + onDeleteEdge?: (id: string) => void; + onRemoveService?: (nodeId: string, slug: string) => void; + /** Live (post-deploy) service mutation, hitting W3 endpoints directly. + * Distinct from onRemoveService which queues a design-time graph + * mutation. Both can coexist; the inspector picks based on + * topologyStatus (active/degraded → live, pending/anything else → + * design-time only). Wiring these props from MazeNET.tsx is the + * single switch that turns chips into live controls. */ + onLiveAddService?: (nodeName: string, slug: string) => void; + onLiveRemoveService?: (nodeName: string, slug: string) => Promise; + /** Per-decky-eligible service slugs, fetched via useServiceRegistry. */ + availableServices?: string[]; + /** Toggle ``forwards_l3`` (gateway) on the selected decky. When the + * topology is active/degraded the caller is responsible for the + * destructive-recreate confirm dialog and the ``force: true`` submit + * — this prop just relays the user's intent. */ + onToggleGateway?: (nodeId: string, nextValue: boolean) => Promise; + /** Tarpit controls — only shown when topology is active/degraded and node is a deployed decky. */ + onLiveTarpitEnable?: (nodeName: string, ports: number[], delayMs: number) => Promise; + onLiveTarpitDisable?: (nodeName: string) => Promise; + onAddDecky?: (netId: string) => void; + setSelection?: (sel: Selection) => void; + pendingChanges?: number; + className?: string; +} + +const Inspector: React.FC = ({ + selection, nets, nodes, edges, topologyId, topologyStatus, onClose, + onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService, + onLiveAddService, onLiveRemoveService, availableServices, + onToggleGateway, onLiveTarpitEnable, onLiveTarpitDisable, + onAddDecky, setSelection, + pendingChanges = 0, + className = '', +}) => { + 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; + const serviceSel = selection?.type === 'service' ? selection : undefined; + + const activeNetIds = useMemo(() => { + const s = new Set(); + edges.forEach((e) => { + const f = nodes.find((n) => n.id === e.from); + const t = nodes.find((n) => n.id === e.to); + if (f) s.add(f.netId); + if (t) s.add(t.netId); + }); + return s; + }, [edges, nodes]); + + const typeLabel = selection ? selection.type.toUpperCase() : 'IDLE'; + const selectionKey = selection?.type === 'node' ? selection.id : undefined; + + return ( + + ); +}; + +export default Inspector; diff --git a/decnet_web/src/components/MazeNET/Inspector/types.ts b/decnet_web/src/components/MazeNET/Inspector/types.ts new file mode 100644 index 00000000..e2e4c8f8 --- /dev/null +++ b/decnet_web/src/components/MazeNET/Inspector/types.ts @@ -0,0 +1,6 @@ +export type Selection = + | { type: 'net'; id: string } + | { type: 'node'; id: string } + | { type: 'edge'; id: string } + | { type: 'service'; id: string; nodeId: string } + | null;