diff --git a/services/mam-api/src/routes/cluster.js b/services/mam-api/src/routes/cluster.js index 4d5e8a6..5756002 100644 --- a/services/mam-api/src/routes/cluster.js +++ b/services/mam-api/src/routes/cluster.js @@ -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) => { diff --git a/services/web-ui/public/js/bmd-card.js b/services/web-ui/public/js/bmd-card.js index 1256dce..d9615ec 100644 --- a/services/web-ui/public/js/bmd-card.js +++ b/services/web-ui/public/js/bmd-card.js @@ -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 — 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); }); diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 0d93cbb..f972226 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -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 ( +
+
+ + Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '— none reported'} +
+ {sel.bmdPorts.length === 0 && ( +
No DeckLink cards detected on this node
+ )} + {sel.bmdPorts.length > 0 && ( +
+ {/* Card header */} +
+ + + {sel.bmdPorts[0].model || "Blackmagic DeckLink"} + + + {sel.bmdCount} PORT{sel.bmdCount !== 1 ? 'S' : ''} + +
+ + {/* Port chips with signal state */} +
+ {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 ( +
+ {/* Signal presence dot */} + + + {p.device ? p.device.split('/').pop() : `port ${p.index}`} + + {sig && ( + + {label} + + )} + {sigEntry && sigEntry.currentFps != null && ( + + {Number(sigEntry.currentFps).toFixed(1)} fps + + )} +
+ ); + })} +
+ + {/* BMD SVG card diagram */} +
+
+ )} +
+ ); +} + +// 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() {
{/* ── Capture cards ── */} -
-
- - Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '— none reported'} -
- {sel.bmdPorts.length === 0 && ( -
No DeckLink cards detected on this node
- )} - {sel.bmdPorts.length > 0 && ( -
-
- - - {sel.bmdPorts[0].model || "Blackmagic DeckLink"} - - - {sel.bmdCount} PORT{sel.bmdCount !== 1 ? 'S' : ''} - -
-
- {sel.bmdPorts.map((p, i) => ( -
- {p.device ? p.device.split('/').pop() : `port ${p.index}`} -
- ))} -
-
- )} -
+
diff --git a/services/web-ui/public/styles-fixes.css b/services/web-ui/public/styles-fixes.css index 615901d..34d9830 100644 --- a/services/web-ui/public/styles-fixes.css +++ b/services/web-ui/public/styles-fixes.css @@ -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; } +}