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 => {