feat(cluster): full hardware breakdown per node

_normalizeNode:
- Maps cpu_usage, mem_used_mb/mem_total_mb from actual API fields
- Reads capabilities.gpus: name, memory_mb, device, bound status
  (bound = nvidia-smi confirmed driver, detected by name+memory_mb)
- Reads capabilities.blackmagic + blackmagic_model: model, port count,
  device paths

Node detail panel:
- GPUs: name, VRAM, device path, BOUND/UNBOUND badge (green if driver active)
- Capture cards: model name, port count badge, per-port device name
  with online/offline color coding

Stat row: adds Capture ports total count card

Topology SVG: shows GPU count and BMD port count under each node label

Fix: removeNode uses node.dbId (UUID) not node.id (hostname)
This commit is contained in:
Zac Gaetano 2026-05-26 18:06:30 +00:00
parent e4d4c00f52
commit 55ff2e717f

View file

@ -1,18 +1,46 @@
// screens-admin.jsx Users, Tokens, Containers, Cluster (graph), Settings
function _normalizeNode(n, x, y) {
const cap = n.capabilities || {};
// GPUs: capabilities.gpus entries with name+memory_mb = driver-bound (nvidia-smi confirmed).
// Entries with only type+device = detected by /dev file but driver status unknown.
const gpus = (cap.gpus || []).map(g => ({
name: g.name || (g.type ? g.type.toUpperCase() : 'GPU'),
memMb: g.memory_mb || null,
index: g.index ?? 0,
device: g.device || null,
bound: !!(g.name && g.memory_mb), // name+memory = nvidia-smi confirmed driver bound
}));
// Blackmagic DeckLink: capabilities.blackmagic + capabilities.blackmagic_model
const bmdPorts = (cap.blackmagic || []).map(b => ({
index: b.index ?? 0,
device: b.device || null,
model: cap.blackmagic_model || null,
online: b.online !== false,
}));
const memUsedMb = n.mem_used_mb || n.memory_used_mb || (n.mem && n.mem < 1000 ? n.mem * 1024 : n.mem || 0);
const memTotalMb = n.mem_total_mb || n.memory_total_mb || (n.memTotal && n.memTotal < 1000 ? n.memTotal * 1024 : n.memTotal || 0);
return {
id: n.id || n.hostname || n.name || 'node',
id: n.hostname || n.id || n.name || 'node',
dbId: n.id,
role: n.role || 'worker',
status: n.status || (n.online ? 'online' : 'offline'),
ip: n.ip || n.ip_address || '—',
ip: n.ip_address || n.ip || '—',
version: n.version || '—',
uptime: n.uptime || '—',
cpu: n.cpu || n.cpu_percent || 0,
mem: n.mem || n.memory_used || n.memory_used_gb || 0,
memTotal: n.memTotal || n.mem_total || n.memory_total || n.memory_total_gb || 0,
gpus: n.gpus || (n.gpu_count ? Array(n.gpu_count).fill('GPU') : []),
devices: n.devices || n.capture_devices || [],
cpu: parseFloat(n.cpu_usage || n.cpu || n.cpu_percent || 0),
mem: Math.round(memUsedMb / 1024 * 10) / 10,
memTotal: Math.round(memTotalMb / 1024 * 10) / 10,
// Raw capabilities for the hardware panel
gpus,
bmdPorts,
// Legacy flat arrays kept for the stat-row summary cards
gpuCount: gpus.length,
bmdCount: bmdPorts.length,
x, y,
};
}
@ -978,7 +1006,7 @@ function Cluster() {
const removeNode = (node) => {
if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine — it only removes it from cluster membership.')) return;
window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.id), { method: 'DELETE' })
window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.dbId || node.id), { method: 'DELETE' })
.then(() => refresh())
.catch(e => setAdviceModal({ title: 'Remove failed', lines: [e.message] }));
};
@ -1011,7 +1039,11 @@ function Cluster() {
</div>
<div className="stat-card">
<div className="label"><Icon name="gpu" size={12} />GPUs</div>
<div className="value">{NODES.reduce((a, n) => a + n.gpus.length, 0)}</div>
<div className="value">{NODES.reduce((a, n) => a + n.gpuCount, 0)}</div>
</div>
<div className="stat-card">
<div className="label"><Icon name="video" size={12} />Capture ports</div>
<div className="value">{NODES.reduce((a, n) => a + n.bmdCount, 0)}</div>
</div>
<div className="stat-card">
<div className="label"><Icon name="hdd" size={12} />Avg Memory</div>
@ -1077,6 +1109,11 @@ function Cluster() {
{n.role !== "primary" && <text textAnchor="middle" y="3" fill="var(--text-2)" fontSize="10" fontFamily="var(--font-mono)">{n.role[0].toUpperCase()}</text>}
<text textAnchor="middle" y="40" fill={isSelected ? "var(--text-1)" : "var(--text-2)"} fontSize="11" fontWeight={isSelected ? 600 : 500}>{n.id}</text>
<text textAnchor="middle" y="54" fill="var(--text-3)" fontSize="10" fontFamily="var(--font-mono)">{n.ip}</text>
{(n.gpuCount > 0 || n.bmdCount > 0) && (
<text textAnchor="middle" y="67" fill="var(--text-4)" fontSize="9.5" fontFamily="var(--font-mono)">
{[n.gpuCount > 0 && `${n.gpuCount}×GPU`, n.bmdCount > 0 && `${n.bmdCount}×BMD`].filter(Boolean).join(' ')}
</text>
)}
</g>
);
})}
@ -1113,27 +1150,81 @@ function Cluster() {
</div>
} />
)}
{sel.gpus.length > 0 && (
{/* ── GPU hardware ── */}
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6 }}>GPUs ({sel.gpus.length})</div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="gpu" size={11} />
GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '— none reported'}
</div>
{sel.gpus.length === 0 && (
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No GPUs detected on this node</div>
)}
{sel.gpus.map((g, i) => (
<div key={i} className="mono" style={{ fontSize: 11.5, padding: "5px 8px", background: "var(--bg-2)", borderRadius: 4, marginBottom: 4, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="gpu" size={11} style={{ color: "var(--text-3)" }} />
<span>{g}</span>
<div key={i} style={{
padding: "7px 10px", background: "var(--bg-2)", borderRadius: 5, marginBottom: 4,
border: g.bound ? "1px solid rgba(91,250,138,0.25)" : "1px solid var(--border)",
display: "flex", alignItems: "center", gap: 8,
}}>
<Icon name="gpu" size={12} style={{ color: g.bound ? "var(--success)" : "var(--text-3)", flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-1)" }}>{g.name}</div>
{g.memMb && (
<div style={{ fontSize: 11, color: "var(--text-3)", fontFamily: "var(--font-mono)", marginTop: 1 }}>
{g.memMb >= 1024 ? (g.memMb / 1024).toFixed(1) + ' GB' : g.memMb + ' MB'} VRAM
</div>
)}
{g.device && <div style={{ fontSize: 10.5, color: "var(--text-4)", fontFamily: "var(--font-mono)" }}>{g.device}</div>}
</div>
<span style={{
fontSize: 10, fontWeight: 600, padding: "2px 6px", borderRadius: 3,
background: g.bound ? "rgba(91,250,138,0.15)" : "rgba(255,255,255,0.05)",
color: g.bound ? "var(--success)" : "var(--text-3)",
}}>
{g.bound ? "BOUND" : "UNBOUND"}
</span>
</div>
))}
</div>
)}
{sel.devices && sel.devices.length > 0 && (
{/* ── Capture cards ── */}
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6 }}>Capture devices</div>
{sel.devices.map((d, i) => (
<div key={i} className="mono" style={{ fontSize: 11.5, padding: "5px 8px", background: "var(--bg-2)", borderRadius: 4 }}>
<Icon name="video" size={11} style={{ color: "var(--text-3)", marginRight: 6 }} />{d}
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="video" size={11} />
Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '— none reported'}
</div>
{sel.bmdPorts.length === 0 && (
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No DeckLink cards detected on this node</div>
)}
{sel.bmdPorts.length > 0 && (
<div style={{
padding: "8px 10px", background: "var(--bg-2)", borderRadius: 5,
border: "1px solid rgba(91,124,250,0.2)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
<Icon name="video" size={13} style={{ color: "var(--accent)" }} />
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-1)" }}>
{sel.bmdPorts[0].model || "Blackmagic DeckLink"}
</span>
<span style={{ marginLeft: "auto", fontSize: 10, fontWeight: 600, padding: "2px 6px", borderRadius: 3, background: "rgba(91,124,250,0.15)", color: "var(--accent)" }}>
{sel.bmdCount} PORT{sel.bmdCount !== 1 ? 'S' : ''}
</span>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{sel.bmdPorts.map((p, i) => (
<div key={i} style={{
fontSize: 10.5, fontFamily: "var(--font-mono)",
padding: "2px 7px", borderRadius: 3,
background: p.online !== false ? "rgba(91,250,138,0.1)" : "rgba(255,255,255,0.05)",
color: p.online !== false ? "var(--success)" : "var(--text-3)",
border: `1px solid ${p.online !== false ? "rgba(91,250,138,0.25)" : "var(--border)"}`,
}}>
{p.device ? p.device.split('/').pop() : `port ${p.index}`}
</div>
))}
</div>
</div>
)}
</div>
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
<button className="btn ghost sm" onClick={() => nodeLogsHint(sel)}>Logs</button>
<button className="btn ghost sm" onClick={() => drainNode(sel)}>Drain</button>