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 ===== */
|
||||
|
||||
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 }) {
|
||||
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 (
|
||||
<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];
|
||||
const totalPorts = devices.length;
|
||||
const receivingPorts = Object.values(portSignals).filter(e => e.signal === 'receiving').length;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<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" />
|
||||
{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>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
|
||||
{devices.map((d, i) => (
|
||||
<button key={i} className={'btn ' + (activeIdx === i ? 'primary' : 'ghost') + ' sm'} onClick={() => setActiveIdx(i)}>
|
||||
{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>
|
||||
{totalPorts === 0 ? (
|
||||
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
No DeckLink devices found in cluster.
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue