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:
parent
de311321f4
commit
7a6113fc90
2 changed files with 245 additions and 42 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue