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:
parent
e4d4c00f52
commit
55ff2e717f
1 changed files with 123 additions and 32 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue