diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index d1233e6..60f772a 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -715,64 +715,233 @@ function badgeForStatus(s) { } /* ===== Capture ===== */ + +function _captureSignalChip(sig) { + switch (sig) { + case 'receiving': return { label: 'RECEIVING', color: 'var(--success)', pulse: true }; + case 'connecting': return { label: 'CONNECTING', color: 'var(--accent)', pulse: true }; + case 'lost': return { label: 'LOST', color: 'var(--danger)', pulse: false }; + case 'error': return { label: 'ERROR', color: 'var(--danger)', pulse: false }; + case 'idle': return { label: 'IDLE', color: 'var(--text-3)', pulse: false }; + case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)', pulse: false }; + default: return { label: sig || '—', color: 'var(--text-4)', pulse: false }; + } +} + +function CapturePortChip({ port, sigEntry }) { + const sig = sigEntry ? sigEntry.signal : null; + const { label, color, pulse } = _captureSignalChip(sig); + const isReceiving = sig === 'receiving'; + const portLabel = port.device ? port.device.split('/').pop() : `port ${port.index}`; + + return ( +
+ +
+
+ {portLabel} +
+
+ + {label} + + {sigEntry && sigEntry.currentFps != null && ( + + {Number(sigEntry.currentFps).toFixed(1)} fps + + )} +
+
+
+ ); +} + +function CaptureNodeCard({ node, ports, portSignals }) { + const svgRef = React.useRef(null); + const nodeSignalMap = React.useMemo(() => { + const map = new Map(); + ports.forEach(p => { + const entry = portSignals[`${node.node_id}:${p.index}`]; + if (entry) map.set(p.index, entry.signal); + }); + return map; + }, [node.node_id, ports, portSignals]); + + React.useEffect(() => { + if (!svgRef.current || !window.BMDCards || ports.length === 0) return; + svgRef.current.innerHTML = ''; + const svg = window.BMDCards.render({ + model: ports[0].model || '', + deviceCount: ports.length, + compact: true, + portSignals: nodeSignalMap, + }); + if (svg) svgRef.current.appendChild(svg); + }, [node.node_id, ports.length, nodeSignalMap]); + + const receivingCount = ports.filter(p => { + const e = portSignals[`${node.node_id}:${p.index}`]; + return e && e.signal === 'receiving'; + }).length; + + return ( +
+ {/* Node header */} +
+ +
+
+ {ports[0].model || 'DeckLink'} +
+
+ {node.hostname}{node.ip_address ? ` · ${node.ip_address}` : ''} +
+
+
+ {receivingCount > 0 && ( + + {receivingCount} LIVE + + )} + + {ports.length} PORT{ports.length !== 1 ? 'S' : ''} + +
+
+ + {/* Port chips */} +
+ {ports.map(p => ( + + ))} +
+ + {/* BMD card SVG diagram */} + {window.BMDCards && ( +
+ )} +
+ ); +} + function Capture({ navigate }) { const [devices, setDevices] = React.useState([]); - const [activeIdx, setActiveIdx] = React.useState(0); + const [portSignals, setPortSignals] = React.useState({}); + const [lastPoll, setLastPoll] = React.useState(null); - const loadDevices = () => { + // Group devices by node + const nodeGroups = React.useMemo(() => { + const map = new Map(); + devices.forEach(d => { + const key = d.node_id || d.hostname || 'unknown'; + if (!map.has(key)) map.set(key, { node_id: d.node_id, hostname: d.hostname, ip_address: d.ip_address, online: d.online, ports: [] }); + map.get(key).ports.push(d); + }); + return Array.from(map.values()); + }, [devices]); + + // Load device list once (changes rarely) + const loadDevices = React.useCallback(() => { window.ZAMPP_API.fetch('/cluster/devices/blackmagic') .then(devs => setDevices(Array.isArray(devs) ? devs : [])) .catch(() => setDevices([])); - }; + }, []); + + // Poll signal state every 3s + 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); + setLastPoll(new Date()); + }) + .catch(() => {}); + }; + poll(); + const id = setInterval(poll, 3000); + return () => clearInterval(id); + }, []); React.useEffect(() => { loadDevices(); }, []); - if (devices.length === 0) { - return ( -
-
-

Capture

- DeckLink SDI ingest -
- -
-
-
- No DeckLink devices found in cluster. -
-
-
- ); - } - - const active = devices[activeIdx] || devices[0]; + const totalPorts = devices.length; + const receivingPorts = Object.values(portSignals).filter(e => e.signal === 'receiving').length; return (

Capture

- DeckLink SDI ingest — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster + DeckLink SDI ingest
+ {totalPorts > 0 && ( +
+ {receivingPorts > 0 && ( + + {receivingPorts}/{totalPorts} LIVE + + )} + {lastPoll && ( + + updated {lastPoll.toLocaleTimeString()} + + )} +
+ )}
-
- {devices.map((d, i) => ( - - ))} -
-
-
- -
-
{active.model || active.device || 'DeckLink'}
-
{active.hostname} · {active.ip_address}
-
+ {totalPorts === 0 ? ( +
+ No DeckLink devices found in cluster.
-
Connect a source and click Refresh to see port status.
-
+ ) : ( +
+ {nodeGroups.map(node => ( + + ))} +
+ )}
); diff --git a/services/web-ui/public/shell.jsx b/services/web-ui/public/shell.jsx index d2cec94..812cd8e 100644 --- a/services/web-ui/public/shell.jsx +++ b/services/web-ui/public/shell.jsx @@ -82,6 +82,7 @@ function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) function Sidebar({ active, onNavigate, me, collapsed, onToggle }) { const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"])); const [jobsBadge, setJobsBadge] = React.useState(null); + const [captureBadge, setCaptureBadge] = React.useState(null); // Live jobs count (#130) — poll /jobs/count for active jobs and render the // result as the sidebar badge. Falls back to hidden on error. @@ -102,10 +103,43 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) { return () => { cancelled = true; clearInterval(id); }; }, []); - // Apply the live jobs badge to the Jobs nav item. + // Live DeckLink signal presence — poll every 5s, badge shows receiving port count. + React.useEffect(() => { + let cancelled = false; + const tick = () => { + window.ZAMPP_API.fetch('/cluster/devices/blackmagic/signal') + .then(entries => { + if (cancelled) return; + const all = Array.isArray(entries) ? entries : []; + const live = all.filter(e => e.signal === 'receiving').length; + const total = all.length; + if (total === 0) { setCaptureBadge(null); return; } + setCaptureBadge(live > 0 + ? { kind: 'live', text: `${live}/${total}` } + : { kind: 'neutral', text: `0/${total}` }); + }) + .catch(() => setCaptureBadge(null)); + }; + tick(); + const id = setInterval(tick, 5000); + return () => { cancelled = true; clearInterval(id); }; + }, []); + + // Apply live badges to nav items. const navTree = React.useMemo( - () => NAV_TREE.map(n => n.id === 'jobs' && jobsBadge ? { ...n, badge: jobsBadge } : n), - [jobsBadge] + () => NAV_TREE.map(n => { + if (n.id === 'jobs' && jobsBadge) return { ...n, badge: jobsBadge }; + if (n.id === 'ingest' && n.children) { + return { + ...n, + children: n.children.map(c => + c.id === 'capture' && captureBadge ? { ...c, badge: captureBadge } : c + ), + }; + } + return n; + }), + [jobsBadge, captureBadge] ); const toggleGroup = (id) => { setOpenGroups(prev => {