diff --git a/decnet_web/src/components/MazeNET/Inspector.tsx b/decnet_web/src/components/MazeNET/Inspector.tsx index 739871f0..8672c40e 100644 --- a/decnet_web/src/components/MazeNET/Inspector.tsx +++ b/decnet_web/src/components/MazeNET/Inspector.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { ArrowLeft, ArrowRight, Crosshair, Globe, GitMerge, MousePointer2, Plus, Server, Trash2, X, Shield, @@ -24,6 +24,16 @@ interface Props { 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) => Promise; + onLiveRemoveService?: (nodeName: string, slug: string) => Promise; + /** Per-decky-eligible service slugs, fetched via useServiceRegistry. */ + availableServices?: string[]; onAddDecky?: (netId: string) => void; setSelection?: (sel: Selection) => void; pendingChanges?: number; @@ -32,10 +42,20 @@ interface Props { const Inspector: React.FC = ({ selection, nets, nodes, edges, topologyStatus, onClose, - onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService, onAddDecky, setSelection, + onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService, + onLiveAddService, onLiveRemoveService, availableServices = [], + 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); 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; @@ -107,9 +127,116 @@ const Inspector: React.FC = ({
{node.services.length === 0 && } {node.services.map((s) => ( - {s} + + {s} + {liveOpsEnabled && node.kind !== 'observed' && ( + + )} + ))} + {liveOpsEnabled && node.kind !== 'observed' && !addOpen && ( + + )}
+ {liveOpsEnabled && addOpen && ( +
+ + + +
+ )} + {opError && ( +
{opError}
+ )}
diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index ff906d8e..399caa18 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -23,6 +23,7 @@ import { useLayoutPersistor } from './useMazeLayoutStore'; import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream'; import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data'; import { useToast } from '../Toasts/useToast'; +import { useServiceRegistry } from '../../hooks/useServiceRegistry'; /* Short unique suffix for default names — avoids the DB uniqueness * constraint regardless of delete/re-add sequencing on the client. */ @@ -428,6 +429,33 @@ const MazeNET: React.FC = () => { } }; + /* Live service add/remove — talks to the W3 endpoints directly, + bypassing the design-time mutation queue. Used when topology + status is active/degraded; the Inspector switches between this + and the design-time path based on the topologyStatus prop. + + Optimistic local update is fine: the W3 endpoint returns the + post-mutation services list, and the SSE forwarder (commit C-sse) + reconciles cross-tab. */ + const serviceRegistry = useServiceRegistry(); + + const liveAddService = useCallback(async (nodeName: string, slug: string) => { + const { data } = await axios.post<{ services: string[] }>( + `/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/services`, + { name: slug }, + ); + setNodes((p) => p.map((x) => x.kind === 'decky' && x.name === nodeName + ? { ...x, services: data.services } : x)); + }, [topologyId]); + + const liveRemoveService = useCallback(async (nodeName: string, slug: string) => { + const { data } = await axios.delete<{ services: string[] }>( + `/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/services/${encodeURIComponent(slug)}`, + ); + setNodes((p) => p.map((x) => x.kind === 'decky' && x.name === nodeName + ? { ...x, services: data.services } : x)); + }, [topologyId]); + /* 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) => { @@ -795,6 +823,9 @@ const MazeNET: React.FC = () => { onDeleteNode={removeNode} onDeleteEdge={removeEdge} onRemoveService={removeServiceFromNode} + availableServices={serviceRegistry.perDecky} + onLiveAddService={liveAddService} + onLiveRemoveService={liveRemoveService} onAddDecky={(netId) => { const net = nets.find((n) => n.id === netId); if (!net) return;