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:
parent
d257a19d9d
commit
a44d8bd7c9
4 changed files with 360 additions and 39 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue