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:
@@ -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 && (
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
42
decnet_web/src/hooks/useSwarmHosts.ts
Normal file
42
decnet_web/src/hooks/useSwarmHosts.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user