fix(web-ui): fix duplicate DeckLink groups in new-recorder modal; refactor device pickers

The /cluster/devices/blackmagic endpoint returns one entry per port (flat
array). The old SDI picker iterated over each entry and synthesised
port_count buttons per entry — 4 entries × 4 synthesised ports = 16 buttons
rendered as 4 identical duplicate groups.

Fix: extract DevicePortPicker component that groups the flat per-port
response by node_id (Map keyed on node_id, one group per physical node,
ports sorted by index). One button rendered per actual API entry.

Also extract ManualDevicePicker for the fallback empty-state dropdowns.
Both components shared between SDI and Deltacast pickers.

Visual improvements:
- Port label shows device node (io0, io1…) from device path instead of
  redundant index number
- Node header only shows model+hostname, not repeated per port
- TEST CARD badge styled inline for Deltacast test-card ports
This commit is contained in:
Zac Gaetano 2026-05-28 23:18:55 +00:00
parent 888ca65045
commit f54c49d2dc

View file

@ -1,4 +1,103 @@
// modal-new-recorder.jsx New Recorder dialog (SRT / RTMP / SDI)
// modal-new-recorder.jsx New Recorder dialog (SRT / RTMP / SDI / Deltacast)
/**
* DevicePortPicker groups a flat per-port API response by node_id and
* renders one button per actual port. Replaces the old code that iterated
* over entries and synthesised port counts, which caused duplicate groups.
*
* props:
* ports flat array from /cluster/devices/blackmagic or /deltacast
* each entry: { node_id, hostname, model, index, device, present? }
* selectedIdx currently selected device_index
* selectedNode currently selected node_id
* onSelect(idx, nodeId)
* portLabel e.g. "SDI" or "Port"
* showTestBadge show TEST CARD badge when present===false
*/
function DevicePortPicker({ ports, selectedIdx, selectedNode, onSelect, portLabel = 'Port', showTestBadge = false }) {
// Group by node_id (stable one group per physical node)
const groups = React.useMemo(() => {
const map = new Map();
for (const p of ports) {
const key = p.node_id || p.hostname || 'unknown';
if (!map.has(key)) map.set(key, { nodeId: p.node_id || p.hostname || '', hostname: p.hostname || key, model: p.model || '', ports: [] });
map.get(key).ports.push(p);
}
// Sort ports within each group by index
for (const g of map.values()) g.ports.sort((a, b) => a.index - b.index);
return Array.from(map.values());
}, [ports]);
return (
<div className="sdi-port-mini">
{groups.map(group => (
<div key={group.nodeId} style={{ marginBottom: groups.length > 1 ? 12 : 4 }}>
{/* Node header — only show when multiple groups, or always for clarity */}
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '0 0 6px' }}>
{group.model ? group.model.toUpperCase() + ' · ' : ''}{group.hostname}
</div>
{group.ports.map(port => {
const active = selectedIdx === port.index && selectedNode === group.nodeId;
return (
<button key={port.index}
className={`sdi-mini-port${active ? ' active' : ''}`}
onClick={() => onSelect(port.index, group.nodeId)}>
<span className="sdi-mini-radio"><span className="sdi-mini-radio-dot" /></span>
<span style={{ fontSize: 12, fontWeight: 600 }}>
{portLabel} {port.index + 1}
{showTestBadge && port.present === false && (
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--accent)', background: 'var(--accent-soft)', borderRadius: 3, padding: '1px 5px', marginLeft: 7 }}>
TEST CARD
</span>
)}
</span>
<span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 'auto', fontFamily: 'var(--font-mono)' }}>
{port.device ? port.device.split('/').pop() : `index ${port.index}`}
</span>
</button>
);
})}
</div>
))}
</div>
);
}
/**
* ManualDevicePicker fallback when no devices detected. Lets the operator
* pick node + index from dropdowns.
*/
function ManualDevicePicker({ nodes, nodeId, deviceIdx, portLabel, portCount, onNodeChange, onIdxChange, emptyNote }) {
return (
<div style={{ padding: '4px 0' }}>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
{emptyNote || `No ${portLabel} devices auto-detected. Configure manually:`}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">Capture node</label>
<select className="field-input" value={nodeId}
onChange={e => onNodeChange(e.target.value)} style={{ appearance: 'auto' }}>
{nodes.length === 0
? <option value="">No cluster nodes</option>
: nodes.map(n => {
const id = n.id || n.hostname || n.name || '';
return <option key={id} value={id}>{n.hostname || n.name || id}</option>;
})}
</select>
</div>
<div className="field">
<label className="field-label">{portLabel} index</label>
<select className="field-input" value={deviceIdx}
onChange={e => onIdxChange(Number(e.target.value))} style={{ appearance: 'auto' }}>
{Array.from({ length: portCount }, (_, i) =>
<option key={i} value={i}>{portLabel} {i + 1} (index {i})</option>)}
</select>
</div>
</div>
</div>
);
}
function ProbeResult({ result }) {
if (!result.ok) {
@ -212,54 +311,24 @@ function NewRecorderModal({ open, onClose }) {
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting DeckLink devices</div>
)}
{sdiDevices !== null && sdiDevices.length > 0 && (
<div className="sdi-port-mini">
{sdiDevices.map((dev, di) => (
<div key={di} style={{ marginBottom: 8 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '4px 0 8px' }}>
{(dev.model || dev.device || 'DeckLink').toUpperCase()} · {dev.hostname}
</div>
{Array.from({ length: dev.port_count || 4 }, (_, i) => i).map(idx => (
<button key={idx}
className={`sdi-mini-port ${sdiDeviceIdx === idx && sdiNodeId === (dev.node_id || dev.hostname || '') ? 'active' : ''}`}
onClick={() => { setSdiDeviceIdx(idx); setSdiNodeId(dev.node_id || dev.hostname || ''); }}>
<span className="sdi-mini-radio"><span className="sdi-mini-radio-dot" /></span>
<span style={{ fontSize: 12, fontWeight: 600 }}>SDI {idx + 1}</span>
<span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 6 }}>index {idx}</span>
</button>
))}
</div>
))}
</div>
<DevicePortPicker
ports={sdiDevices}
selectedIdx={sdiDeviceIdx}
selectedNode={sdiNodeId}
onSelect={(idx, nodeId) => { setSdiDeviceIdx(idx); setSdiNodeId(nodeId); }}
portLabel="SDI"
/>
)}
{sdiDevices !== null && sdiDevices.length === 0 && (
<div style={{ padding: '8px 0' }}>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
No DeckLink devices auto-detected. Configure manually:
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">Capture node</label>
<select className="field-input" value={sdiNodeId}
onChange={e => setSdiNodeId(e.target.value)} style={{ appearance: 'auto' }}>
{NODES.length === 0
? <option value="">No cluster nodes</option>
: NODES.map(n => {
const id = n.id || n.hostname || n.name || '';
const label = n.hostname || n.name || id;
return <option key={id} value={id}>{label}</option>;
})}
</select>
</div>
<div className="field">
<label className="field-label">Device index</label>
<select className="field-input" value={sdiDeviceIdx}
onChange={e => setSdiDeviceIdx(Number(e.target.value))} style={{ appearance: 'auto' }}>
{[0, 1, 2, 3].map(i =>
<option key={i} value={i}>SDI {i + 1} (index {i})</option>)}
</select>
</div>
</div>
</div>
<ManualDevicePicker
nodes={NODES}
nodeId={sdiNodeId}
deviceIdx={sdiDeviceIdx}
portLabel="SDI"
portCount={4}
onNodeChange={setSdiNodeId}
onIdxChange={setSdiDeviceIdx}
/>
)}
</div>
)}
@ -271,66 +340,26 @@ function NewRecorderModal({ open, onClose }) {
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting Deltacast devices</div>
)}
{dcDevices !== null && dcDevices.length > 0 && (
<div className="sdi-port-mini">
{(() => {
// Group by node
const byNode = {};
dcDevices.forEach(dev => {
const key = dev.node_id || dev.hostname || 'unknown';
if (!byNode[key]) byNode[key] = { ...dev, ports: [] };
byNode[key].ports.push(dev);
});
return Object.values(byNode).map((node, ni) => (
<div key={ni} style={{ marginBottom: 8 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '4px 0 8px' }}>
{(node.model || 'Deltacast').toUpperCase()} · {node.hostname}
</div>
{node.ports.map(port => (
<button key={port.index}
className={`sdi-mini-port ${dcDeviceIdx === port.index && dcNodeId === (port.node_id || port.hostname || '') ? 'active' : ''}`}
onClick={() => { setDcDeviceIdx(port.index); setDcNodeId(port.node_id || port.hostname || ''); }}>
<span className="sdi-mini-radio"><span className="sdi-mini-radio-dot" /></span>
<span style={{ fontSize: 12, fontWeight: 600 }}>
Port {port.index + 1}
{!port.present && <span style={{ fontSize: 10, color: 'var(--accent)', marginLeft: 6 }}>TEST CARD</span>}
</span>
<span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 6 }}>index {port.index}</span>
</button>
))}
</div>
));
})()}
</div>
<DevicePortPicker
ports={dcDevices}
selectedIdx={dcDeviceIdx}
selectedNode={dcNodeId}
onSelect={(idx, nodeId) => { setDcDeviceIdx(idx); setDcNodeId(nodeId); }}
portLabel="Port"
showTestBadge
/>
)}
{dcDevices !== null && dcDevices.length === 0 && (
<div style={{ padding: '8px 0' }}>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
No Deltacast devices detected. Configure manually (test-card mode):
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">Capture node</label>
<select className="field-input" value={dcNodeId}
onChange={e => setDcNodeId(e.target.value)} style={{ appearance: 'auto' }}>
{NODES.length === 0
? <option value="">No cluster nodes</option>
: NODES.map(n => {
const id = n.id || n.hostname || n.name || '';
const label = n.hostname || n.name || id;
return <option key={id} value={id}>{label}</option>;
})}
</select>
</div>
<div className="field">
<label className="field-label">Port index</label>
<select className="field-input" value={dcDeviceIdx}
onChange={e => setDcDeviceIdx(Number(e.target.value))} style={{ appearance: 'auto' }}>
{[0, 1, 2, 3, 4, 5, 6, 7].map(i =>
<option key={i} value={i}>Port {i + 1} (index {i})</option>)}
</select>
</div>
</div>
</div>
<ManualDevicePicker
nodes={NODES}
nodeId={dcNodeId}
deviceIdx={dcDeviceIdx}
portLabel="Port"
portCount={8}
onNodeChange={setDcNodeId}
onIdxChange={setDcDeviceIdx}
emptyNote="No Deltacast devices detected. Configure manually (test-card mode):"
/>
)}
</div>
)}