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;