feat(admin): live video-presence indicators on cluster DeckLink ports

Adds per-port video signal state to the admin Cluster panel:

- New GET /cluster/devices/blackmagic/signal endpoint joins recorders by
  node_id+device_index and queries each active capture container's
  /capture/status (local: http://recorder-<id>:3001, remote: api_url/
  sidecar/<container_id>/status).  Returns receiving/connecting/lost/
  error/idle/no-recorder per port plus framesReceived and currentFps.

- bmd-card.js render() now accepts portSignals (Map or object) and
  overlays a colored dot on each BNC connector with pulse animation
  for receiving/connecting states.

- screens-admin.jsx Cluster panel polls the new endpoint every 5s,
  feeds the signal map into both the port chips (now show
  RECEIVING/CONNECTING/LOST + fps) and the BMD SVG card diagram
  rendered below them via a new BmdCardPanel component.

- styles-fixes.css adds bmd-card-* styles for the SVG diagram and
  bmd-port-signal --pulse animation.
This commit is contained in:
opencode 2026-05-26 22:02:38 +00:00
parent d257a19d9d
commit a44d8bd7c9
4 changed files with 360 additions and 39 deletions

View file

@ -146,6 +146,113 @@ router.post('/heartbeat', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /devices/blackmagic/signal live video-presence state for every
// DeckLink port across the cluster. For each port we check whether there is
// an active SDI recorder assigned to it and, if so, query the capture
// container for its real signal state (receiving / lost / connecting /
// error). Ports without a recorder get signal = 'no-recorder'.
//
// Response shape (array):
// { node_id, hostname, index, device, model,
// signal, framesReceived, currentFps, recorder_id, recorder_status }
router.get('/devices/blackmagic/signal', async (req, res, next) => {
try {
// 1. Fetch all cluster nodes with DeckLink capabilities.
const nodesResult = await pool.query(
`SELECT id, hostname, ip_address, api_url, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes
WHERE capabilities IS NOT NULL`
);
// 2. Fetch all SDI recorders that are pinned to a node+device_index.
const recResult = await pool.query(
`SELECT id, name, status, container_id, node_id, device_index,
source_config
FROM recorders
WHERE source_type = 'sdi' AND node_id IS NOT NULL`
);
// Build a fast lookup: "${node_id}:${device_index}" → recorder row.
const recByPort = new Map();
for (const r of recResult.rows) {
const devIdx = r.device_index ?? r.source_config?.device ?? 0;
recByPort.set(`${r.node_id}:${devIdx}`, r);
}
// 3. For each port, determine signal state. We fire all capture-container
// fetches concurrently so the endpoint stays fast even with many ports.
const tasks = [];
for (const node of nodesResult.rows) {
const nodeOnline = Number(node.stale_seconds) < 120;
const bm = (node.capabilities && node.capabilities.blackmagic) || [];
const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
const localHostname = process.env.NODE_HOSTNAME || '';
const isRemote = node.api_url && node.hostname !== localHostname;
bm.forEach((d, idx) => {
const portIndex = d.index !== undefined ? d.index : idx;
const rec = recByPort.get(`${node.id}:${portIndex}`);
tasks.push((async () => {
const base = {
node_id: node.id,
hostname: node.hostname,
index: portIndex,
device: d.device || null,
model,
node_online: nodeOnline,
recorder_id: rec ? rec.id : null,
recorder_name: rec ? rec.name : null,
recorder_status: rec ? rec.status : null,
signal: 'no-recorder',
framesReceived: null,
currentFps: null,
};
if (!rec || rec.status !== 'recording' || !rec.container_id) {
// No active capture — if there's a recorder but it's not recording,
// report that; otherwise the port is unassigned.
if (rec && rec.status !== 'recording') base.signal = 'idle';
return base;
}
// Active recording — query the capture container for real signal.
try {
let live = null;
if (isRemote) {
const r = await fetch(
`${node.api_url}/sidecar/${rec.container_id}/status`,
{ signal: AbortSignal.timeout(2500) }
);
if (r.ok) live = (await r.json()).live;
} else {
const r = await fetch(
`http://recorder-${rec.id}:3001/capture/status`,
{ signal: AbortSignal.timeout(2000) }
);
if (r.ok) live = await r.json();
}
if (live && live.signal) {
base.signal = live.signal;
base.framesReceived = live.framesReceived ?? null;
base.currentFps = live.currentFps ?? null;
} else {
base.signal = 'connecting';
}
} catch (_) {
base.signal = 'connecting';
}
return base;
})());
});
}
const results = await Promise.all(tasks);
res.json(results);
} catch (err) { next(err); }
});
// GET /devices/blackmagic flatten every node's DeckLink cards for the
// recorder picker. Returns one entry per device with the host node info.
router.get('/devices/blackmagic', async (req, res, next) => {

View file

@ -140,6 +140,17 @@ window.BMDCards = (function () {
};
}
// Signal state → visual properties for the overlay dot.
// Colors are CSS custom-property strings so they inherit the app theme.
const SIGNAL_STYLE = {
'receiving': { fill: 'var(--success, #2dd4a8)', pulse: true },
'connecting': { fill: 'var(--accent, #5b7cfa)', pulse: true },
'lost': { fill: 'var(--danger, #f87171)', pulse: false },
'error': { fill: 'var(--danger, #f87171)', pulse: false },
'idle': { fill: 'var(--text-3, #7a8194)', pulse: false },
'no-recorder': { fill: 'var(--text-4, #4a4f61)', pulse: false },
};
/**
* Render the SVG card.
*
@ -149,8 +160,12 @@ window.BMDCards = (function () {
* selectedIndex currently-selected port index
* onSelect (index) => void
* compact drop the model label + footer (default false)
* portSignals Map<portIndex, signalState> overlays a presence dot on
* each port. signalState is one of: 'receiving', 'connecting',
* 'lost', 'error', 'idle', 'no-recorder'. Omit or pass null
* to render the card without signal indicators.
*/
function render({ model, deviceCount, selectedIndex, onSelect, compact }) {
function render({ model, deviceCount, selectedIndex, onSelect, compact, portSignals }) {
const m = resolveModel(model) || genericModel(deviceCount || 2);
// If we resolved a model but the cluster reports more ports than we
@ -231,6 +246,35 @@ window.BMDCards = (function () {
class: 'bmd-port-sublabel',
})).textContent = p.role;
// Video-presence indicator — small dot overlaid on the BNC connector.
// Shown whenever portSignals is provided, even for unassigned ports.
if (portSignals) {
const sig = portSignals instanceof Map
? portSignals.get(i)
: (portSignals[i] ?? null);
const sigKey = sig || 'no-recorder';
const style = SIGNAL_STYLE[sigKey] || SIGNAL_STYLE['no-recorder'];
// Dot positioned at top-right of the BNC ring so it doesn't obscure
// the connector centre. HDMI connectors get a slight offset.
const dotR = 4;
const dotCx = isHdmi ? p.cx + 12 : p.cx + 9;
const dotCy = isHdmi ? p.cy - 8 : p.cy - 9;
const dot = el('circle', {
cx: dotCx, cy: dotCy, r: dotR,
class: 'bmd-port-signal' + (style.pulse ? ' bmd-port-signal--pulse' : ''),
fill: style.fill,
'data-signal': sigKey,
});
g.appendChild(dot);
// Tooltip title on the group for hover text.
const title = document.createElementNS(NS, 'title');
title.textContent = sigKey.replace('-', ' ');
g.appendChild(title);
}
svg.appendChild(g);
});

View file

@ -923,9 +923,126 @@ function Containers() {
);
}
//
// BmdCardPanel capture-card section inside the Cluster node detail panel.
// Shows port chips with live video-presence dots AND the BMD SVG card diagram.
//
function BmdCardPanel({ sel, portSignals }) {
const svgRef = React.useRef(null);
// Build the port-index signal-entry map for the selected node.
const nodeSignalMap = React.useMemo(() => {
const map = new Map();
sel.bmdPorts.forEach((p) => {
const key = `${sel.dbId}:${p.index}`;
const entry = portSignals[key];
if (entry) map.set(p.index, entry.signal);
});
return map;
}, [sel.dbId, sel.bmdPorts, portSignals]);
// (Re-)render the SVG card diagram whenever the node or signals change.
React.useEffect(() => {
if (!svgRef.current || !window.BMDCards) return;
if (sel.bmdPorts.length === 0) return;
svgRef.current.innerHTML = '';
const svg = window.BMDCards.render({
model: sel.bmdPorts[0].model || '',
deviceCount: sel.bmdCount,
compact: true,
portSignals: nodeSignalMap,
});
svgRef.current.appendChild(svg);
}, [sel.dbId, sel.bmdCount, nodeSignalMap]);
return (
<div>
<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)" }}>
{/* Card header */}
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
<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>
{/* Port chips with signal state */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 10 }}>
{sel.bmdPorts.map((p) => {
const sigEntry = portSignals[`${sel.dbId}:${p.index}`];
const sig = sigEntry ? sigEntry.signal : (p.online !== false ? null : 'offline');
const { label, color } = _signalChip(sig);
const isReceiving = sig === 'receiving';
return (
<div key={p.index} title={sigEntry ? `${sigEntry.recorder_name || 'recorder'}${label}` : label}
style={{
display: "flex", alignItems: "center", gap: 5,
fontSize: 10.5, fontFamily: "var(--font-mono)",
padding: "3px 8px", borderRadius: 3,
background: isReceiving ? "rgba(45,212,168,0.1)" : "rgba(255,255,255,0.04)",
border: `1px solid ${isReceiving ? "rgba(45,212,168,0.3)" : "var(--border)"}`,
}}>
{/* Signal presence dot */}
<span style={{
width: 6, height: 6, borderRadius: "50%", flexShrink: 0,
background: sig ? color : "var(--text-4)",
animation: isReceiving ? "signalPulse 1.4s ease-in-out infinite" : "none",
}} />
<span style={{ color: "var(--text-2)" }}>
{p.device ? p.device.split('/').pop() : `port ${p.index}`}
</span>
{sig && (
<span style={{ color, fontSize: 9, fontWeight: 700, marginLeft: 2, letterSpacing: "0.04em" }}>
{label}
</span>
)}
{sigEntry && sigEntry.currentFps != null && (
<span style={{ color: "var(--text-4)", fontSize: 9 }}>
{Number(sigEntry.currentFps).toFixed(1)} fps
</span>
)}
</div>
);
})}
</div>
{/* BMD SVG card diagram */}
<div ref={svgRef} className="bmd-card-diagram" />
</div>
)}
</div>
);
}
// Signal state { label, color } for the port chip indicator.
function _signalChip(sig) {
switch (sig) {
case 'receiving': return { label: 'RECEIVING', color: 'var(--success)' };
case 'connecting': return { label: 'CONNECTING', color: 'var(--accent)' };
case 'lost': return { label: 'LOST', color: 'var(--danger)' };
case 'error': return { label: 'ERROR', color: 'var(--danger)' };
case 'idle': return { label: 'IDLE', color: 'var(--text-3)' };
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)' };
default: return { label: sig || '—', color: 'var(--text-4)' };
}
}
function Cluster() {
const [nodesData, setNodesData] = React.useState(window.ZAMPP_DATA.NODES);
const [hovered, setHovered] = React.useState(null);
// Map of "node_id:portIndex" signal entry from /cluster/devices/blackmagic/signal
const [portSignals, setPortSignals] = React.useState({});
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/cluster')
@ -936,6 +1053,22 @@ function Cluster() {
.catch(() => {});
}, []);
// Poll live video-presence state for all DeckLink ports every 5 s.
React.useEffect(() => {
const poll = () => {
window.ZAMPP_API.fetch('/cluster/devices/blackmagic/signal')
.then(entries => {
const map = {};
(entries || []).forEach(e => { map[`${e.node_id}:${e.index}`] = e; });
setPortSignals(map);
})
.catch(() => {});
};
poll();
const id = setInterval(poll, 5000);
return () => clearInterval(id);
}, []);
const nodesArr = Array.isArray(nodesData) ? nodesData : (nodesData?.nodes || []);
const NODES = React.useMemo(() => {
@ -1187,44 +1320,7 @@ function Cluster() {
</div>
{/* ── Capture cards ── */}
<div>
<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>
<BmdCardPanel sel={sel} portSignals={portSignals} />
<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>

View file

@ -535,3 +535,77 @@
0%, 100% { box-shadow: 0 0 0 0 rgba(45, 212, 168, 0.6); }
50% { box-shadow: 0 0 0 6px rgba(45, 212, 168, 0); }
}
/* ============================================================
BMD card diagram rendered inside the Cluster node panel.
The SVG is generated by bmd-card.js; styles live here so
they inherit the app CSS custom properties at render time.
============================================================ */
.bmd-card-diagram {
width: 100%;
overflow: hidden;
}
.bmd-card-svg {
width: 100%;
height: auto;
display: block;
}
.bmd-card-body {
fill: var(--bg-3, #1e2130);
stroke: var(--border, #2d3147);
stroke-width: 1;
}
.bmd-card-bracket {
fill: var(--bg-1, #13151f);
stroke: var(--border, #2d3147);
stroke-width: 1.5;
}
.bmd-card-trace {
stroke: rgba(91, 124, 250, 0.12);
stroke-width: 0.5;
fill: none;
}
.bmd-port-group {
transition: opacity 0.15s;
}
.bmd-port-group:hover {
opacity: 0.85;
}
.bmd-port-ring {
fill: var(--bg-1, #13151f);
stroke: var(--border, #2d3147);
stroke-width: 1.5;
}
.bmd-port-pin {
fill: var(--text-4, #4a4f61);
}
.bmd-port-label {
fill: var(--text-1, #e8eaf6);
font-size: 10px;
font-weight: 600;
font-family: var(--font-mono, monospace);
}
.bmd-port-sublabel {
fill: var(--text-3, #7a8194);
font-size: 8.5px;
font-family: var(--font-mono, monospace);
}
.bmd-card-model {
fill: var(--text-4, #4a4f61);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
font-family: var(--font-mono, monospace);
}
/* Signal presence dot overlaid on each BNC connector */
.bmd-port-signal {
opacity: 0.95;
filter: drop-shadow(0 0 2px currentColor);
}
.bmd-port-signal--pulse {
animation: bmdPortPulse 1.4s ease-in-out infinite;
}
@keyframes bmdPortPulse {
0%, 100% { opacity: 0.95; r: 4; }
50% { opacity: 0.6; r: 5; }
}