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:
parent
888ca65045
commit
f54c49d2dc
1 changed files with 134 additions and 105 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue