From a1bc8a878b54e93e43e244f16ff2f5d0494e0156 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 25 Apr 2026 03:29:49 -0400 Subject: [PATCH] feat(web/mazenet): show target host in topology list + war map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the swarm host (or "master") that a topology is deployed to, both as a meta line on each topology list card and in the war-map header. Operators can now distinguish master-local from agent-targeted topologies at a glance — previously the only signal was the abstract "mode: agent" label, with no hint of which agent. Adds useSwarmHosts() hook for the uuid → host lookup. Falls back to a short uuid prefix when the hosts list is unavailable so the UI never hard-fails on a missing /swarm/hosts response. TopologySummary gains target_host_uuid in the frontend type so the field actually narrows when checked. --- decnet_web/src/components/MazeNET/MazeNET.tsx | 16 +++++++ .../src/components/MazeNET/useMazeApi.ts | 1 + .../components/TopologyList/TopologyList.tsx | 16 ++++++- decnet_web/src/hooks/useSwarmHosts.ts | 42 +++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 decnet_web/src/hooks/useSwarmHosts.ts diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index f2f4d080..d34d8257 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -7,6 +7,7 @@ import { } from '../../icons'; import './MazeNET.css'; import axios from '../../utils/api'; +import { useSwarmHosts } from '../../hooks/useSwarmHosts'; import Palette from './Palette'; import Canvas from './Canvas'; import Inspector from './Inspector'; @@ -39,12 +40,15 @@ const MazeNET: React.FC = () => { const [params] = useSearchParams(); const topologyId = params.get('topology') ?? ''; + const { byUuid: hostsByUuid } = useSwarmHosts(); const [nets, setNets] = useState([]); const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); const [topoStatus, setTopoStatus] = useState('pending'); const [topoName, setTopoName] = useState(''); const [topoVersion, setTopoVersion] = useState(0); + const [topoTargetHost, setTopoTargetHost] = useState(null); + const [topoMode, setTopoMode] = useState('unihost'); const [selection, setSelection] = useState(null); const [inspectorOpen, setInspectorOpen] = useState(true); const [paletteOpen, setPaletteOpen] = useState(true); @@ -580,6 +584,8 @@ const MazeNET: React.FC = () => { setTopoStatus(h.topology.status); setTopoName(h.topology.name); setTopoVersion(h.topology.version); + setTopoMode(h.topology.mode ?? 'unihost'); + setTopoTargetHost(h.topology.target_host_uuid ?? null); setLoadErr(null); } catch (err) { setLoadErr((err as Error)?.message ?? 'topology load failed'); @@ -658,6 +664,16 @@ const MazeNET: React.FC = () => {

MAZENET · {topoName || topologyId}

NETWORK OF NETWORKS · {topoStatus.toUpperCase()} · v{topoVersion} ·{' '} + HOST:{' '} + {topoMode === 'agent' && topoTargetHost ? ( + + + {hostsByUuid.get(topoTargetHost)?.name ?? topoTargetHost.slice(0, 8)} + + ) : ( + MASTER + )} + {' · '} {nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS ·{' '} {runningDeckies}/{deckyNodes.length} DECKIES RUNNING {streamEnabled && ( diff --git a/decnet_web/src/components/MazeNET/useMazeApi.ts b/decnet_web/src/components/MazeNET/useMazeApi.ts index 9157783f..9166fb52 100644 --- a/decnet_web/src/components/MazeNET/useMazeApi.ts +++ b/decnet_web/src/components/MazeNET/useMazeApi.ts @@ -40,6 +40,7 @@ export interface TopologySummary { id: string; name: string; mode: string; + target_host_uuid: string | null; status: string; version: number; } diff --git a/decnet_web/src/components/TopologyList/TopologyList.tsx b/decnet_web/src/components/TopologyList/TopologyList.tsx index e54442c3..175ad74b 100644 --- a/decnet_web/src/components/TopologyList/TopologyList.tsx +++ b/decnet_web/src/components/TopologyList/TopologyList.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Network, Plus, Power, Trash2, UploadCloud, RefreshCw, Skull } from '../../icons'; +import { Network, Plus, Power, Trash2, UploadCloud, RefreshCw, Skull, Server, Cpu } from '../../icons'; import api from '../../utils/api'; +import { useSwarmHosts } from '../../hooks/useSwarmHosts'; import { clearLayout } from '../MazeNET/useMazeLayoutStore'; import CreateTopologyWizard from './CreateTopologyWizard'; import EmptyState from '../EmptyState/EmptyState'; @@ -42,6 +43,7 @@ const statusClass = (s: string): string => { const TopologyList: React.FC = () => { const navigate = useNavigate(); + const { byUuid: hostsByUuid } = useSwarmHosts(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); @@ -205,7 +207,17 @@ const TopologyList: React.FC = () => { {r.status}
- mode: {r.mode} + {r.mode === 'agent' && r.target_host_uuid ? ( + + + {hostsByUuid.get(r.target_host_uuid)?.name ?? `host:${r.target_host_uuid.slice(0, 8)}`} + + ) : ( + + + master + + )} v{r.version} {new Date(r.created_at).toLocaleString()}
diff --git a/decnet_web/src/hooks/useSwarmHosts.ts b/decnet_web/src/hooks/useSwarmHosts.ts new file mode 100644 index 00000000..0633fc59 --- /dev/null +++ b/decnet_web/src/hooks/useSwarmHosts.ts @@ -0,0 +1,42 @@ +import { useCallback, useEffect, useState } from 'react'; +import api from '../utils/api'; + +export interface SwarmHost { + uuid: string; + name: string; + address: string; + agent_port: number; + status: string; + last_heartbeat: string | null; +} + +/** + * Lookup of enrolled swarm hosts. One-shot fetch on mount, with a manual + * refresh callback. Used to resolve `target_host_uuid` → display name in + * places where we don't already have a host name in hand (topology list, + * war-map header). + * + * Failure is treated as "no agents enrolled" — callers fall back to the + * uuid prefix or a generic label rather than blocking on this lookup. + */ +export function useSwarmHosts(): { + hosts: SwarmHost[]; + byUuid: Map; + refresh: () => Promise; +} { + const [hosts, setHosts] = useState([]); + + const refresh = useCallback(async () => { + try { + const { data } = await api.get('/swarm/hosts'); + setHosts(data ?? []); + } catch { + setHosts([]); + } + }, []); + + useEffect(() => { refresh(); }, [refresh]); + + const byUuid = new Map(hosts.map((h) => [h.uuid, h])); + return { hosts, byUuid, refresh }; +}