From 1f429cd00e55dfc95a4c73120acf02d1f292c374 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 22 Apr 2026 15:56:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20MazeNET=207b=20=E2=80=94=20service?= =?UTF-8?q?-level=20selection=20+=20inspector=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a service tag selects it (stops node drag), extends Selection discriminant with {type:'service',id,nodeId}, and renders an inspector panel showing proto/port/subnet/risk chip + REMOVE SERVICE button (gated off for observed nodes and degraded topologies). Service-tag styling now pulls `risk` from DEFAULT_SERVICES metadata instead of node.status alone. --- decnet_web/src/components/MazeNET/Canvas.tsx | 10 +++- .../src/components/MazeNET/Inspector.tsx | 55 ++++++++++++++++++- decnet_web/src/components/MazeNET/MazeNET.tsx | 17 ++++++ .../src/components/MazeNET/NodeCard.tsx | 29 ++++++++-- 4 files changed, 101 insertions(+), 10 deletions(-) diff --git a/decnet_web/src/components/MazeNET/Canvas.tsx b/decnet_web/src/components/MazeNET/Canvas.tsx index f01b7e60..e118a61f 100644 --- a/decnet_web/src/components/MazeNET/Canvas.tsx +++ b/decnet_web/src/components/MazeNET/Canvas.tsx @@ -30,6 +30,7 @@ interface Props { onAutoLayout?: () => void; sseConnected?: boolean; lastEventAt?: Date | null; + onSelectService?: (nodeId: string, slug: string) => void; } const fmtTime = (d: Date) => @@ -42,7 +43,7 @@ const Canvas = forwardRef(function Canvas( { nets, nodes, edges, deployed, selection, setSelection, pan, dropTargetId, dragging, edgeDraw, onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown, onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu, - onResetView, onAutoLayout, sseConnected, lastEventAt }, + onResetView, onAutoLayout, sseConnected, lastEventAt, onSelectService }, ref, ) { const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]); @@ -63,8 +64,11 @@ const Canvas = forwardRef(function Canvas( }, [nodes, edges]); const selNetId = selection?.type === 'net' ? selection.id : null; - const selNodeId = selection?.type === 'node' ? selection.id : null; + const selNodeId = selection?.type === 'node' ? selection.id + : selection?.type === 'service' ? selection.nodeId : null; const selEdgeId = selection?.type === 'edge' ? selection.id : null; + const selServiceNodeId = selection?.type === 'service' ? selection.nodeId : null; + const selServiceSlug = selection?.type === 'service' ? selection.id : null; return (
(function Canvas( selected={n.id === selNodeId} deployed={deployed} dragging={dragging && n.id === selNodeId} + selectedServiceSlug={n.id === selServiceNodeId ? selServiceSlug : null} onSelect={(id) => setSelection({ type: 'node', id })} + onSelectService={onSelectService} onMouseDown={onNodeMouseDown} onPortMouseDown={onPortMouseDown} onContextMenu={onNodeContextMenu} diff --git a/decnet_web/src/components/MazeNET/Inspector.tsx b/decnet_web/src/components/MazeNET/Inspector.tsx index fff5a3e5..a93e6d0e 100644 --- a/decnet_web/src/components/MazeNET/Inspector.tsx +++ b/decnet_web/src/components/MazeNET/Inspector.tsx @@ -1,14 +1,16 @@ import React, { useMemo } from 'react'; import { ArrowLeft, ArrowRight, Crosshair, Globe, GitMerge, MousePointer2, Plus, - Server, Trash2, X, + Server, Trash2, X, Shield, } from 'lucide-react'; import type { Net, MazeNode, Edge } from './types'; +import { DEFAULT_SERVICES } from './data'; export type Selection = | { type: 'net'; id: string } | { type: 'node'; id: string } | { type: 'edge'; id: string } + | { type: 'service'; id: string; nodeId: string } | null; interface Props { @@ -21,6 +23,7 @@ interface Props { onDeleteNet?: (id: string) => void; onDeleteNode?: (id: string) => void; onDeleteEdge?: (id: string) => void; + onRemoveService?: (nodeId: string, slug: string) => void; onAddDecky?: (netId: string) => void; setSelection?: (sel: Selection) => void; pendingChanges?: number; @@ -28,12 +31,16 @@ interface Props { const Inspector: React.FC = ({ selection, nets, nodes, edges, topologyStatus, onClose, - onDeleteNet, onDeleteNode, onDeleteEdge, onAddDecky, setSelection, + onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService, onAddDecky, setSelection, pendingChanges = 0, }) => { 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(); @@ -224,6 +231,50 @@ const Inspector: React.FC = ({ )} + {serviceSel && ( + <> +
+ + + {serviceMeta?.name ?? serviceSel.id.toUpperCase()} + + {serviceMeta && ( + + {serviceMeta.risk.toUpperCase()} + + )} +
+
+
EXPOSED ON
+
{serviceParent?.name ?? '—'}
+
PROTOCOL
+
{(serviceMeta?.proto ?? '—').toUpperCase()}
+
PORT
+
{serviceMeta?.port ?? '—'}
+
SUBNET
+
{serviceParentNet?.label ?? '—'}
+
+ {onRemoveService && serviceParent && serviceParent.kind !== 'observed' && ( + + )} + + )} + {pendingChanges > 0 && (
PENDING DIFF
diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index 0a9fd766..dc055f45 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -276,6 +276,21 @@ const MazeNET: React.FC = () => { } }; + const removeServiceFromNode = async (id: string, slug: string) => { + const n = nodes.find((x) => x.id === id); + if (!n || n.kind !== 'decky' || !n.services.includes(slug)) return; + const nextServices = n.services.filter((s) => s !== slug); + try { + const r = await editor.updateDecky(topologyId, id, n.name, { services: nextServices }); + if (r.kind !== 'applied') return; + setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky' + ? { ...x, services: nextServices } : x)); + setSelection(null); + } catch (err) { + flashErr(err, 'remove service failed'); + } + }; + const addServiceToNode = async (id: string, slug: string) => { const n = nodes.find((x) => x.id === id); if (!n || n.kind !== 'decky' || n.services.includes(slug)) return; @@ -584,6 +599,7 @@ const MazeNET: React.FC = () => { onAutoLayout={() => pushToast({ text: 'AUTO-LAYOUT COMING SOON', tone: 'violet', icon: 'info' })} sseConnected={streamLive} lastEventAt={lastEventAt} + onSelectService={(nodeId, slug) => setSelection({ type: 'service', id: slug, nodeId })} /> {ctxMenu && ( setCtxMenu(null)} /> @@ -608,6 +624,7 @@ const MazeNET: React.FC = () => { onDeleteNet={removeNet} onDeleteNode={removeNode} onDeleteEdge={removeEdge} + onRemoveService={removeServiceFromNode} onAddDecky={(netId) => { const net = nets.find((n) => n.id === netId); if (!net) return; diff --git a/decnet_web/src/components/MazeNET/NodeCard.tsx b/decnet_web/src/components/MazeNET/NodeCard.tsx index 412bc75c..e6970802 100644 --- a/decnet_web/src/components/MazeNET/NodeCard.tsx +++ b/decnet_web/src/components/MazeNET/NodeCard.tsx @@ -4,6 +4,7 @@ import { type LucideIcon, } from 'lucide-react'; import type { MazeNode } from './types'; +import { DEFAULT_SERVICES } from './data'; const ARCHETYPE_ICONS: Record = { 'linux-server': Server, @@ -24,13 +25,15 @@ interface Props { selected: boolean; dragging?: boolean; deployed?: boolean; + selectedServiceSlug?: string | null; onSelect?: (id: string) => void; + onSelectService?: (nodeId: string, slug: string) => void; onMouseDown?: (id: string) => (e: React.MouseEvent) => void; onPortMouseDown?: (id: string) => (e: React.MouseEvent) => void; onContextMenu?: (id: string) => (e: React.MouseEvent) => void; } -const NodeCard: React.FC = ({ node, absX, absY, selected, dragging, deployed, onSelect, onMouseDown, onPortMouseDown, onContextMenu }) => { +const NodeCard: React.FC = ({ node, absX, absY, selected, dragging, deployed, selectedServiceSlug, onSelect, onSelectService, onMouseDown, onPortMouseDown, onContextMenu }) => { const isDmzGateway = !!(node as { decky_config?: { forwards_l3?: boolean } }).decky_config?.forwards_l3; const classes = [ 'maze-node', @@ -65,11 +68,25 @@ const NodeCard: React.FC = ({ node, absX, absY, selected, dragging, deplo
{node.archetype.toUpperCase()}
{node.services.length > 0 && (
- {node.services.map((s) => ( - - {s} - - ))} + {node.services.map((s) => { + const meta = DEFAULT_SERVICES.find((x) => x.slug === s); + const isHigh = meta?.risk === 'high' || node.status === 'hot'; + const isSel = selectedServiceSlug === s; + return ( + { + if (!onSelectService) return; + e.stopPropagation(); + onSelectService(node.id, s); + }} + > + {s} + + ); + })}
)} {node.kind === 'decky' && <>