diff --git a/services/web-ui/public/modal-new-recorder.jsx b/services/web-ui/public/modal-new-recorder.jsx
index ea87bba..f10292a 100644
--- a/services/web-ui/public/modal-new-recorder.jsx
+++ b/services/web-ui/public/modal-new-recorder.jsx
@@ -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 (
+
+ {groups.map(group => (
+
1 ? 12 : 4 }}>
+ {/* Node header — only show when multiple groups, or always for clarity */}
+
+ {group.model ? group.model.toUpperCase() + ' · ' : ''}{group.hostname}
+
+ {group.ports.map(port => {
+ const active = selectedIdx === port.index && selectedNode === group.nodeId;
+ return (
+
+ );
+ })}
+
+ ))}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ {emptyNote || `No ${portLabel} devices auto-detected. Configure manually:`}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
function ProbeResult({ result }) {
if (!result.ok) {
@@ -212,54 +311,24 @@ function NewRecorderModal({ open, onClose }) {
Detecting DeckLink devices…
)}
{sdiDevices !== null && sdiDevices.length > 0 && (
-
- {sdiDevices.map((dev, di) => (
-
-
- {(dev.model || dev.device || 'DeckLink').toUpperCase()} · {dev.hostname}
-
- {Array.from({ length: dev.port_count || 4 }, (_, i) => i).map(idx => (
-
- ))}
-
- ))}
-
+ { setSdiDeviceIdx(idx); setSdiNodeId(nodeId); }}
+ portLabel="SDI"
+ />
)}
{sdiDevices !== null && sdiDevices.length === 0 && (
-
-
- No DeckLink devices auto-detected. Configure manually:
-
-
-
-
-
-
-
-
-
-
-
-
+
)}
)}
@@ -271,66 +340,26 @@ function NewRecorderModal({ open, onClose }) {
Detecting Deltacast devices…
)}
{dcDevices !== null && dcDevices.length > 0 && (
-
- {(() => {
- // 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) => (
-
-
- {(node.model || 'Deltacast').toUpperCase()} · {node.hostname}
-
- {node.ports.map(port => (
-
- ))}
-
- ));
- })()}
-
+ { setDcDeviceIdx(idx); setDcNodeId(nodeId); }}
+ portLabel="Port"
+ showTestBadge
+ />
)}
{dcDevices !== null && dcDevices.length === 0 && (
-
-
- No Deltacast devices detected. Configure manually (test-card mode):
-
-
-
-
-
-
-
-
-
-
-
-
+
)}
)}