feat(web/mazenet): show target host in topology list + war map

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.
This commit is contained in:
2026-04-25 03:29:49 -04:00
parent ee176a6f79
commit a1bc8a878b
4 changed files with 73 additions and 2 deletions

View File

@@ -7,6 +7,7 @@ import {
} from '../../icons'; } from '../../icons';
import './MazeNET.css'; import './MazeNET.css';
import axios from '../../utils/api'; import axios from '../../utils/api';
import { useSwarmHosts } from '../../hooks/useSwarmHosts';
import Palette from './Palette'; import Palette from './Palette';
import Canvas from './Canvas'; import Canvas from './Canvas';
import Inspector from './Inspector'; import Inspector from './Inspector';
@@ -39,12 +40,15 @@ const MazeNET: React.FC = () => {
const [params] = useSearchParams(); const [params] = useSearchParams();
const topologyId = params.get('topology') ?? ''; const topologyId = params.get('topology') ?? '';
const { byUuid: hostsByUuid } = useSwarmHosts();
const [nets, setNets] = useState<Net[]>([]); const [nets, setNets] = useState<Net[]>([]);
const [nodes, setNodes] = useState<MazeNode[]>([]); const [nodes, setNodes] = useState<MazeNode[]>([]);
const [edges, setEdges] = useState<Edge[]>([]); const [edges, setEdges] = useState<Edge[]>([]);
const [topoStatus, setTopoStatus] = useState<string>('pending'); const [topoStatus, setTopoStatus] = useState<string>('pending');
const [topoName, setTopoName] = useState<string>(''); const [topoName, setTopoName] = useState<string>('');
const [topoVersion, setTopoVersion] = useState<number>(0); const [topoVersion, setTopoVersion] = useState<number>(0);
const [topoTargetHost, setTopoTargetHost] = useState<string | null>(null);
const [topoMode, setTopoMode] = useState<string>('unihost');
const [selection, setSelection] = useState<Selection>(null); const [selection, setSelection] = useState<Selection>(null);
const [inspectorOpen, setInspectorOpen] = useState(true); const [inspectorOpen, setInspectorOpen] = useState(true);
const [paletteOpen, setPaletteOpen] = useState(true); const [paletteOpen, setPaletteOpen] = useState(true);
@@ -580,6 +584,8 @@ const MazeNET: React.FC = () => {
setTopoStatus(h.topology.status); setTopoStatus(h.topology.status);
setTopoName(h.topology.name); setTopoName(h.topology.name);
setTopoVersion(h.topology.version); setTopoVersion(h.topology.version);
setTopoMode(h.topology.mode ?? 'unihost');
setTopoTargetHost(h.topology.target_host_uuid ?? null);
setLoadErr(null); setLoadErr(null);
} catch (err) { } catch (err) {
setLoadErr((err as Error)?.message ?? 'topology load failed'); setLoadErr((err as Error)?.message ?? 'topology load failed');
@@ -658,6 +664,16 @@ const MazeNET: React.FC = () => {
<h1>MAZENET · {topoName || topologyId}</h1> <h1>MAZENET · {topoName || topologyId}</h1>
<div className="maze-page-sub"> <div className="maze-page-sub">
NETWORK OF NETWORKS · {topoStatus.toUpperCase()} · v{topoVersion} ·{' '} NETWORK OF NETWORKS · {topoStatus.toUpperCase()} · v{topoVersion} ·{' '}
HOST:{' '}
{topoMode === 'agent' && topoTargetHost ? (
<span title={topoTargetHost}>
<Server size={11} style={{ marginRight: 3, verticalAlign: '-1px' }} />
{hostsByUuid.get(topoTargetHost)?.name ?? topoTargetHost.slice(0, 8)}
</span>
) : (
<span>MASTER</span>
)}
{' · '}
{nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS ·{' '} {nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS ·{' '}
{runningDeckies}/{deckyNodes.length} DECKIES RUNNING {runningDeckies}/{deckyNodes.length} DECKIES RUNNING
{streamEnabled && ( {streamEnabled && (

View File

@@ -40,6 +40,7 @@ export interface TopologySummary {
id: string; id: string;
name: string; name: string;
mode: string; mode: string;
target_host_uuid: string | null;
status: string; status: string;
version: number; version: number;
} }

View File

@@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; 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 api from '../../utils/api';
import { useSwarmHosts } from '../../hooks/useSwarmHosts';
import { clearLayout } from '../MazeNET/useMazeLayoutStore'; import { clearLayout } from '../MazeNET/useMazeLayoutStore';
import CreateTopologyWizard from './CreateTopologyWizard'; import CreateTopologyWizard from './CreateTopologyWizard';
import EmptyState from '../EmptyState/EmptyState'; import EmptyState from '../EmptyState/EmptyState';
@@ -42,6 +43,7 @@ const statusClass = (s: string): string => {
const TopologyList: React.FC = () => { const TopologyList: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { byUuid: hostsByUuid } = useSwarmHosts();
const [rows, setRows] = useState<TopologySummary[]>([]); const [rows, setRows] = useState<TopologySummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
@@ -205,7 +207,17 @@ const TopologyList: React.FC = () => {
<span className={`tlist-pill ${statusClass(r.status)}`}>{r.status}</span> <span className={`tlist-pill ${statusClass(r.status)}`}>{r.status}</span>
</div> </div>
<div className="tlist-card-meta"> <div className="tlist-card-meta">
<span>mode: {r.mode}</span> {r.mode === 'agent' && r.target_host_uuid ? (
<span title={r.target_host_uuid}>
<Server size={11} style={{ marginRight: 4, verticalAlign: '-1px' }} />
{hostsByUuid.get(r.target_host_uuid)?.name ?? `host:${r.target_host_uuid.slice(0, 8)}`}
</span>
) : (
<span>
<Cpu size={11} style={{ marginRight: 4, verticalAlign: '-1px' }} />
master
</span>
)}
<span>v{r.version}</span> <span>v{r.version}</span>
<span>{new Date(r.created_at).toLocaleString()}</span> <span>{new Date(r.created_at).toLocaleString()}</span>
</div> </div>

View File

@@ -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<string, SwarmHost>;
refresh: () => Promise<void>;
} {
const [hosts, setHosts] = useState<SwarmHost[]>([]);
const refresh = useCallback(async () => {
try {
const { data } = await api.get<SwarmHost[]>('/swarm/hosts');
setHosts(data ?? []);
} catch {
setHosts([]);
}
}, []);
useEffect(() => { refresh(); }, [refresh]);
const byUuid = new Map(hosts.map((h) => [h.uuid, h]));
return { hosts, byUuid, refresh };
}