capture: live port signal presence indicators on Capture screen and nav badge

- Capture screen now polls /cluster/devices/blackmagic/signal every 3s
- Per-port chips show signal state (RECEIVING/CONNECTING/LOST/ERROR/IDLE) with pulsing dot
- BMD SVG card diagram rendered per node card
- Sidebar nav badge on Capture item shows live/total port count (pulsing green dot)
This commit is contained in:
Zac Gaetano 2026-05-27 13:53:09 +00:00
parent de311321f4
commit 7a6113fc90
2 changed files with 245 additions and 42 deletions

View file

@ -715,64 +715,233 @@ function badgeForStatus(s) {
} }
/* ===== Capture ===== */ /* ===== 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 (
<div
title={sigEntry ? `${sigEntry.recorder_name || 'recorder'}${label}` : label}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 5,
background: isReceiving ? 'rgba(45,212,168,0.08)' : 'var(--bg-2)',
border: `1px solid ${isReceiving ? 'rgba(45,212,168,0.35)' : 'var(--border)'}`,
minWidth: 120,
}}
>
<span style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
background: sig ? color : 'var(--text-4)',
animation: pulse ? 'signalPulse 1.4s ease-in-out infinite' : 'none',
boxShadow: isReceiving ? `0 0 6px ${color}` : 'none',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11.5, fontFamily: 'var(--font-mono)', color: 'var(--text-2)', fontWeight: 600 }}>
{portLabel}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 1 }}>
<span style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.05em', color }}>
{label}
</span>
{sigEntry && sigEntry.currentFps != null && (
<span style={{ fontSize: 9.5, color: 'var(--text-4)', fontFamily: 'var(--font-mono)' }}>
{Number(sigEntry.currentFps).toFixed(1)} fps
</span>
)}
</div>
</div>
</div>
);
}
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 (
<div className="panel" style={{ padding: 0, overflow: 'hidden' }}>
{/* Node header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 16px',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-2)',
}}>
<StatusDot status={node.online !== false ? 'online' : 'offline'} />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>
{ports[0].model || 'DeckLink'}
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', fontFamily: 'var(--font-mono)' }}>
{node.hostname}{node.ip_address ? ` · ${node.ip_address}` : ''}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{receivingCount > 0 && (
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 3,
background: 'rgba(45,212,168,0.15)', color: 'var(--success)',
animation: 'signalPulse 1.4s ease-in-out infinite',
}}>
{receivingCount} LIVE
</span>
)}
<span style={{
fontSize: 10, fontWeight: 600, padding: '2px 7px', borderRadius: 3,
background: 'rgba(91,124,250,0.12)', color: 'var(--accent)',
}}>
{ports.length} PORT{ports.length !== 1 ? 'S' : ''}
</span>
</div>
</div>
{/* Port chips */}
<div style={{ padding: '14px 16px', display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{ports.map(p => (
<CapturePortChip
key={p.index}
port={p}
sigEntry={portSignals[`${node.node_id}:${p.index}`] || null}
/>
))}
</div>
{/* BMD card SVG diagram */}
{window.BMDCards && (
<div ref={svgRef} className="bmd-card-diagram" style={{ padding: '0 16px 14px' }} />
)}
</div>
);
}
function Capture({ navigate }) { function Capture({ navigate }) {
const [devices, setDevices] = React.useState([]); 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') window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
.then(devs => setDevices(Array.isArray(devs) ? devs : [])) .then(devs => setDevices(Array.isArray(devs) ? devs : []))
.catch(() => setDevices([])); .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(); }, []); React.useEffect(() => { loadDevices(); }, []);
if (devices.length === 0) { const totalPorts = devices.length;
return ( const receivingPorts = Object.values(portSignals).filter(e => e.signal === 'receiving').length;
<div className="page">
<div className="page-header">
<h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest</span>
<div className="spacer" />
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
No DeckLink devices found in cluster.
</div>
</div>
</div>
);
}
const active = devices[activeIdx] || devices[0];
return ( return (
<div className="page"> <div className="page">
<div className="page-header"> <div className="page-header">
<h1>Capture</h1> <h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest {devices.length} device{devices.length > 1 ? 's' : ''} in cluster</span> <span className="subtitle">DeckLink SDI ingest</span>
<div className="spacer" /> <div className="spacer" />
{totalPorts > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginRight: 8 }}>
{receivingPorts > 0 && (
<span style={{
fontSize: 11, fontWeight: 700, padding: '3px 8px', borderRadius: 4,
background: 'rgba(45,212,168,0.15)', color: 'var(--success)',
animation: 'signalPulse 1.4s ease-in-out infinite',
}}>
{receivingPorts}/{totalPorts} LIVE
</span>
)}
{lastPoll && (
<span style={{ fontSize: 10.5, color: 'var(--text-4)', fontFamily: 'var(--font-mono)' }}>
updated {lastPoll.toLocaleTimeString()}
</span>
)}
</div>
)}
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button> <button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
</div> </div>
<div className="page-body"> <div className="page-body">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}> {totalPorts === 0 ? (
{devices.map((d, i) => ( <div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
<button key={i} className={'btn ' + (activeIdx === i ? 'primary' : 'ghost') + ' sm'} onClick={() => setActiveIdx(i)}> No DeckLink devices found in cluster.
{d.model || d.device || 'DeckLink'} {d.hostname}
</button>
))}
</div>
<div className="panel" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<StatusDot status={active.online ? 'online' : 'offline'} />
<div>
<div style={{ fontWeight: 600 }}>{active.model || active.device || 'DeckLink'}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{active.hostname} · {active.ip_address}</div>
</div>
</div> </div>
<div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Connect a source and click Refresh to see port status.</div> ) : (
</div> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{nodeGroups.map(node => (
<CaptureNodeCard
key={node.node_id || node.hostname}
node={node}
ports={node.ports}
portSignals={portSignals}
/>
))}
</div>
)}
</div> </div>
</div> </div>
); );

View file

@ -82,6 +82,7 @@ function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup })
function Sidebar({ active, onNavigate, me, collapsed, onToggle }) { function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"])); const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
const [jobsBadge, setJobsBadge] = React.useState(null); 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 // Live jobs count (#130) poll /jobs/count for active jobs and render the
// result as the sidebar badge. Falls back to hidden on error. // 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); }; 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( const navTree = React.useMemo(
() => NAV_TREE.map(n => n.id === 'jobs' && jobsBadge ? { ...n, badge: jobsBadge } : n), () => NAV_TREE.map(n => {
[jobsBadge] 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) => { const toggleGroup = (id) => {
setOpenGroups(prev => { setOpenGroups(prev => {