feat(recorders): hardware-identity model with Enable/Disable lifecycle

Recorders are now physical capture ports, not user-created rows:
- migration 036: label, enabled, auto_provisioned + UNIQUE(node_id,device_index)
  (the structural fix that makes two recorders sharing a port impossible)
- mam-api: auto-provision one recorder row per port from heartbeat capabilities
  (reconcileRecordersForNode); create-once, never overwrites operator config
- mam-api: POST /:id/enable + /:id/disable (provision/teardown standby sidecar);
  PATCH accepts label; config persists across enable/disable
- node-agent: freeCapturePort() force-removes any container on a capture port
  before standby/start — eliminates the EADDRINUSE collisions
- web-ui: recorder menu grouped by node (online/offline), Enable/Disable toggle,
  per-recorder config modal (codec/bitrate/growing/label/project), friendly
  label over hardware name, no destructive delete

Fixes the delete/recreate churn that orphaned standby sidecars and collided on
capture ports during this session's outage.
This commit is contained in:
Zac Gaetano 2026-06-04 03:14:43 +00:00
parent 9f2eac7b61
commit de509c66ab
5 changed files with 500 additions and 53 deletions

View file

@ -0,0 +1,38 @@
-- Migration 036: Recorders become physical hardware, not user-created rows.
--
-- A recorder now maps 1:1 to a physical capture port: (node_id, device_index).
-- mam-api auto-provisions one row per port from each node-agent heartbeat's
-- capabilities (deltacast/blackmagic arrays). Rows are NEVER deleted by the
-- operator — they're discovered, enabled/disabled, and configured in place.
-- This removes the delete/create churn that orphaned standby sidecars and
-- caused capture-port (EADDRINUSE) collisions.
--
-- New columns:
-- label : optional friendly name overlaid on the hardware identity
-- (e.g. "Aurora" for zampp3-dc0). NULL → UI shows node+port name.
-- enabled : operator opt-in. false (default) = no standby sidecar, port idle.
-- true = persistent standby sidecar kept up (idle-preview), ready
-- to record. Toggled by the Enable/Disable button.
-- auto_provisioned : true when the row was created by heartbeat discovery
-- (vs a legacy manually-created recorder). Informational.
--
-- Identity:
-- UNIQUE(node_id, device_index) is the structural guarantee that two
-- recorders can never share a capture port — the root-cause fix for the
-- collisions. Partial unique index (WHERE both are non-null) so any legacy
-- rows without a node/device don't violate it.
ALTER TABLE recorders
ADD COLUMN IF NOT EXISTS label TEXT DEFAULT NULL,
ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS auto_provisioned BOOLEAN NOT NULL DEFAULT false;
-- One recorder per physical port. Partial so pre-existing rows lacking a
-- node_id/device_index (e.g. network sources) are unaffected.
CREATE UNIQUE INDEX IF NOT EXISTS recorders_node_device_uniq
ON recorders (node_id, device_index)
WHERE node_id IS NOT NULL AND device_index IS NOT NULL;
-- Fast lookup of a node's ports during heartbeat reconciliation.
CREATE INDEX IF NOT EXISTS recorders_node_id_idx
ON recorders (node_id);

View file

@ -242,10 +242,65 @@ router.post('/heartbeat', async (req, res, next) => {
metrics != null ? JSON.stringify(metrics) : null,
]
);
// Auto-provision recorder rows from this node's capture hardware. One row
// per physical port, keyed (node_id, device_index). Discovery only — it
// never enables, records, or deletes; the operator opts a port in via the
// Enable button. Non-fatal so a reconcile hiccup never drops a heartbeat.
reconcileRecordersForNode(r.rows[0]).catch(e =>
console.warn(`[recorders] auto-provision for ${hostname} failed (non-fatal): ${e.message}`));
res.json(r.rows[0]);
} catch (err) { next(err); }
});
// Discover capture ports from a node's heartbeat capabilities and upsert one
// recorder row per port. Idempotent via UNIQUE(node_id, device_index): a row
// is created the first time a port is seen (disabled, no sidecar) and left
// untouched on every subsequent heartbeat — operator config/label/enabled
// state is preserved. Ports that vanish are NOT deleted (node may be briefly
// offline); the UI greys them via the node's last_seen.
async function reconcileRecordersForNode(node) {
if (!node || !node.id) return;
const cap = node.capabilities || {};
// Each entry: { source_type, device_index }. Deltacast uses 'port', DeckLink
// uses 'index'; both become device_index (the capture-port offset).
const ports = [];
for (const d of (cap.deltacast || [])) {
const idx = d.index ?? d.port;
if (Number.isInteger(idx)) ports.push({ source_type: 'deltacast', device_index: idx });
}
for (const b of (cap.blackmagic || [])) {
const idx = b.index;
if (Number.isInteger(idx)) ports.push({ source_type: 'blackmagic', device_index: idx });
}
if (ports.length === 0) return;
for (const p of ports) {
// INSERT … ON CONFLICT DO NOTHING: create-once. Never overwrite an existing
// row (preserves label, enabled, codec config, status). source_config keeps
// the legacy {port}/{device} shape the capture pipeline already reads.
const srcCfg = p.source_type === 'deltacast'
? { port: p.device_index }
: { device: p.device_index };
await pool.query(
`INSERT INTO recorders
(node_id, device_index, source_type, source_config, name, enabled, auto_provisioned)
VALUES ($1, $2, $3::source_type, $4, $5, false, true)
ON CONFLICT (node_id, device_index) WHERE node_id IS NOT NULL AND device_index IS NOT NULL
DO NOTHING`,
[
node.id,
p.device_index,
p.source_type,
JSON.stringify(srcCfg),
// Deterministic hardware name; the operator can set a friendly `label`.
`${node.hostname}-${p.source_type === 'deltacast' ? 'dc' : 'bmd'}${p.device_index}`,
]
);
}
}
router.get('/devices/blackmagic/signal', async (req, res, next) => {
try {
const nodesResult = await pool.query(

View file

@ -154,7 +154,7 @@ const RECORDER_FIELDS = [
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
'proxy_container',
'project_id', 'node_id', 'device_index',
'growing_enabled',
'growing_enabled', 'label',
];
function pickRecorderFields(body) {
@ -329,6 +329,31 @@ async function ensureStandbySidecar(recorder) {
return { ok: true, containerId };
}
// Tear down a recorder's standby sidecar (Disable). Asks the node-agent to
// remove the container, then clears container_id and sets status='stopped'.
// Best-effort on the node-agent call — even if the delete fails we still clear
// the row so the operator isn't stuck; the force-free-port logic on the next
// Enable will reclaim a stray container. Returns { ok, reason? }.
async function teardownStandbySidecar(recorder) {
if (recorder.node_id && recorder.container_id) {
const { remote: isRemote, apiUrl: targetNodeApiUrl } =
await resolveNodeTarget(recorder.node_id).catch(() => ({ remote: false }));
if (isRemote && targetNodeApiUrl) {
await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
method: 'DELETE',
signal: AbortSignal.timeout(15000),
}).catch(e => console.warn(`[recorders] sidecar teardown for ${recorder.id} failed (clearing anyway): ${e.message}`));
}
}
await pool.query(
`UPDATE recorders SET container_id = NULL, status = 'stopped', current_session_id = NULL, updated_at = NOW() WHERE id = $1`,
[recorder.id]
);
recorder.container_id = null;
recorder.status = 'stopped';
return { ok: true };
}
// Issue #162 — after a local-spawn stop, wait for the capture container to
// finalize its master. The asset row was pre-created at start with
// status='live' (display_name = current_session_id); the ingest/finalize step
@ -532,6 +557,60 @@ router.post('/reconcile-standby', requireRecorderEdit, async (req, res, next) =>
}
});
// POST /:id/enable - Operator opt-in. Brings up the persistent standby sidecar
// (idle-preview, kept up 24/7) so the port is ready to record in <1s. Sets
// enabled=true. Idempotent: if already enabled with a live container the
// node-agent's force-free-port logic replaces any stale container cleanly.
router.post('/:id/enable', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
const { rows } = await pool.query('SELECT * FROM recorders WHERE id = $1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
const recorder = rows[0];
if (!STANDBY_SOURCE_TYPES.includes(recorder.source_type)) {
return res.status(400).json({ error: `Source type "${recorder.source_type}" does not support standby/enable` });
}
if (!recorder.node_id) {
return res.status(409).json({ error: 'Recorder has no assigned node (hardware offline?) — cannot enable' });
}
const r = await ensureStandbySidecar(recorder);
if (!r.ok) {
return res.status(502).json({ error: `Could not start standby sidecar: ${r.reason || 'unknown'}` });
}
await pool.query('UPDATE recorders SET enabled = true, updated_at = NOW() WHERE id = $1', [id]);
recorder.enabled = true;
res.json(recorder);
} catch (err) {
next(err);
}
});
// POST /:id/disable - Operator opt-out. Stops & removes the standby sidecar,
// freeing the capture port, and sets enabled=false. Config (codec, label,
// growing) is preserved on the row for the next enable. Refuses while the
// recorder is actively recording — stop it first.
router.post('/:id/disable', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
const { rows } = await pool.query('SELECT * FROM recorders WHERE id = $1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
const recorder = rows[0];
if (recorder.status === 'recording') {
return res.status(409).json({ error: 'Recorder is recording — stop it before disabling' });
}
await teardownStandbySidecar(recorder);
await pool.query('UPDATE recorders SET enabled = false, updated_at = NOW() WHERE id = $1', [id]);
recorder.enabled = false;
res.json(recorder);
} catch (err) {
next(err);
}
});
// GET /:id - Get single recorder
router.get('/:id', async (req, res, next) => {
try {

View file

@ -451,6 +451,10 @@ async function handleSidecarStart(body, res) {
gpuUuid = null,
} = body;
// Reclaim the capture port before spawning, so an on-demand start can never
// collide (EADDRINUSE) with a stale/standby container already on that port.
await freeCapturePort(capturePort);
const binds = [`${LIVE_DIR}:/live`];
// Always mount /dev/shm so the sidecar can access framecache slots.
if (fs.existsSync('/dev/shm')) binds.push('/dev/shm:/dev/shm');
@ -696,6 +700,37 @@ async function fetchContainerLogs(containerId) {
// The bridge is started here (warms it up for zero-lag on first /start call).
// Per-session params (CLIP_NAME, ASSET_ID, PROJECT_ID) are NOT in the env —
// they arrive via HTTP POST /capture/start when the user hits record.
// Force-free a capture port before binding a new sidecar to it. With
// NetworkMode=host, two capture containers requesting the same PORT collide
// with EADDRINUSE — the exact failure that orphaned/duplicated sidecars caused.
// We enumerate ALL capture containers (running or not), read each one's PORT
// env, and force-remove any bound to this capturePort. Idempotent and safe:
// the only thing on that port should be a sidecar we're about to replace.
async function freeCapturePort(capturePort) {
try {
// all=1 so we also catch Exited/Created stragglers still holding the name.
const listRes = await dockerApi('GET', '/containers/json?all=1');
if (listRes.status !== 200 || !Array.isArray(listRes.data)) return;
for (const c of listRes.data) {
const img = c.Image || '';
if (!/wild-dragon-capture/.test(img)) continue;
// Inspect to read the PORT env (list payload doesn't include env).
try {
const insp = await dockerApi('GET', `/containers/${c.Id}/json`);
const cenv = (insp.status === 200 && insp.data?.Config?.Env) || [];
const portEnv = cenv.find(e => e.startsWith('PORT='));
const p = portEnv ? parseInt(portEnv.split('=')[1], 10) : NaN;
if (p === capturePort) {
console.log(`[sidecar] force-freeing capture port ${capturePort}: removing stale container ${c.Id.slice(0, 12)}`);
await dockerApi('DELETE', `/containers/${c.Id}?force=true`).catch(() => {});
}
} catch (_) { /* container vanished mid-scan — fine */ }
}
} catch (e) {
console.warn(`[sidecar] freeCapturePort(${capturePort}) scan failed (continuing): ${e.message}`);
}
}
async function handleSidecarStandby(body, res) {
try {
const {
@ -707,6 +742,10 @@ async function handleSidecarStandby(body, res) {
gpuUuid = null,
} = body;
// Reclaim the port first so a re-Enable (or a stale container surviving a
// node-agent restart) can never collide on bind.
await freeCapturePort(capturePort);
const binds = [`${LIVE_DIR}:/live`];
if (fs.existsSync('/dev/shm')) binds.push('/dev/shm:/dev/shm');
if (sourceType === 'sdi' || sourceType === 'blackmagic') binds.unshift('/dev/blackmagic:/dev/blackmagic');

View file

@ -601,39 +601,201 @@ function HlsPreviewUrl({ url }) {
}
/* ===== Recorders ===== */
// Per-recorder config editor. Recorders are physical ports this PATCHes the
// existing row in place (never delete/recreate), so codec/growing/label/project
// changes persist across enable/disable. If the recorder is currently ENABLED,
// saving bounces its standby sidecar (disableenable) so the new env takes
// effect; the operator is told. Refuses while recording.
function RecorderConfigModal({ recorder, onClose, onSaved }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const GROWING_CODEC = 'hevc_nvenc';
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
const [label, setLabel] = React.useState(recorder.label || '');
const [codec, setCodec] = React.useState(recorder.recording_codec || 'hevc_nvenc');
const [bitrate, setBitrate] = React.useState((recorder.recording_video_bitrate || '25').replace(/M$/i, ''));
const [growing, setGrowing] = React.useState(recorder.growing_enabled === true);
const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || '');
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const isRec = recorder.status === 'recording';
const showBitrate = growing || BITRATE_CODECS.has(codec);
const submit = () => {
if (saving || isRec) return;
setSaving(true); setErr(null);
// Growing forces the XDCAM/HEVC master path on the backend; send the GPU
// master codec so the row is coherent if growing is later turned off.
const effCodec = growing ? GROWING_CODEC : codec;
const body = {
label: label.trim() || null,
recording_codec: effCodec,
growing_enabled: growing,
project_id: projectId || null,
};
if (showBitrate && bitrate) body.recording_video_bitrate = String(bitrate).replace(/M$/i, '') + 'M';
window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'PATCH', body: JSON.stringify(body) })
.then(async () => {
// If enabled, bounce the standby sidecar so the new env is applied.
if (recorder.enabled) {
await window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/disable', { method: 'POST' }).catch(() => {});
await window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/enable', { method: 'POST' }).catch(() => {});
}
setSaving(false);
onSaved();
})
.catch(e => { setSaving(false); setErr(e.message || 'Save failed'); });
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 460 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Configure recorder</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 2 }}>
{recorder.hwName}{recorder.capturePort ? ' · ' + recorder.capturePort : ''}
</div>
</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
{isRec && (
<div style={{ marginBottom: 12, padding: 10, background: 'var(--danger-soft, rgba(255,80,80,0.1))', borderRadius: 6, fontSize: 12, color: 'var(--danger)' }}>
Recorder is recording stop it before changing config.
</div>
)}
<div className="field">
<label className="field-label">Label (friendly name)</label>
<input className="field-input" value={label} disabled={isRec}
onChange={e => setLabel(e.target.value)} maxLength={60}
placeholder={recorder.hwName} />
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 4 }}>
Blank = show hardware name ({recorder.hwName})
</div>
</div>
<div className="field">
<label className="field-label">
Video codec{growing && <span style={{ marginLeft: 6, fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--warn, #d9a441)' }}>· LOCKED BY GROWING</span>}
</label>
<select className="field-input" value={growing ? 'h264_growing' : codec}
onChange={e => setCodec(e.target.value)} disabled={growing || isRec}
style={{ appearance: 'auto', opacity: growing ? 0.6 : 1 }}>
{growing && <option value="h264_growing">XDCAM HD422 (MXF OP1a, growing)</option>}
<option value="hevc_nvenc">All-Intra HEVC (NVENC, GPU, growing)</option>
<option value="h264_nvenc">H.264 (NVENC, GPU)</option>
<option value="prores_hq">ProRes 422 HQ (4:2:2, CPU)</option>
<option value="prores">ProRes 422</option>
<option value="prores_lt">ProRes 422 LT</option>
<option value="prores_proxy">ProRes 422 Proxy</option>
<option value="dnxhr_hq">DNxHR HQ</option>
<option value="libx264">H.264 (x264, CPU)</option>
<option value="libx265">H.265 (x265, CPU)</option>
</select>
</div>
{showBitrate && (
<div className="field">
<label className="field-label">Target bitrate (Mbps)</label>
<input className="field-input" type="number" min="1" max="400" step="1"
value={bitrate} disabled={isRec}
onChange={e => setBitrate(e.target.value)} />
</div>
)}
<div className="field">
<label className="field-label">Default project</label>
<select className="field-input" value={projectId} disabled={isRec}
onChange={e => setProjectId(e.target.value)} style={{ appearance: 'auto' }}>
<option value="">(none)</option>
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<label className="field" style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: isRec ? 'default' : 'pointer' }}>
<input type="checkbox" checked={growing} disabled={isRec}
onChange={e => setGrowing(e.target.checked)} />
<div>
<div style={{ fontSize: 13, fontWeight: 500 }}>Growing-file (edit-while-record)</div>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
Writes a growing XDCAM HD422 MXF to the SMB share so editors can cut it live.
</div>
</div>
</label>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving || isRec}>
{saving ? 'Saving…' : 'Save config'}
</button>
</div>
</div>
</div>
);
}
function _normRecorder(r) {
const cfg = r.source_config || {};
// Surface the capture port for SDI / Deltacast recorders so the recorder card
// can show which physical input the recorder is bound to. For Deltacast,
// cfg.port is the bridge port index (0-7). For Blackmagic SDI, cfg.device
// is something like /dev/blackmagic/dv0 we slice off the trailing index.
// Surface the physical capture port. Recorders are now hardware-bound: one row
// per (node, port), so device_index is authoritative. For Deltacast cfg.port,
// for Blackmagic SDI cfg.device (/dev/blackmagic/io0) slice the trailing idx.
let portIdx = r.device_index;
let capturePort = null;
if (r.source_type === 'deltacast') {
capturePort = cfg.port != null ? `Port ${cfg.port}` : null;
} else if (r.source_type === 'sdi') {
const dev = cfg.device || '';
const m = dev.match(/(\d+)$/);
if (m) capturePort = `SDI ${m[1]}`;
portIdx = portIdx ?? cfg.port;
capturePort = portIdx != null ? `Port ${portIdx}` : null;
} else if (r.source_type === 'sdi' || r.source_type === 'blackmagic') {
if (portIdx == null) {
const m = String(cfg.device || '').match(/(\d+)$/);
if (m) portIdx = parseInt(m[1], 10);
}
capturePort = portIdx != null ? `SDI ${portIdx}` : null;
}
return {
...r,
// Friendly label overlays the deterministic hardware name; fall back to name.
displayName: (r.label && r.label.trim()) || r.name,
hwName: r.name,
label: r.label || null,
enabled: r.enabled === true,
autoProvisioned: r.auto_provisioned === true,
source: r.source_type || '·',
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·',
codec: r.recording_codec || '·',
res: r.recording_resolution || '·',
framerate: r.recording_framerate || 'native',
growing: r.growing_enabled === true,
nodeId: r.node_id || null,
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
deviceIndex: portIdx ?? null,
capturePort,
previewUrl: r.preview_url || null,
elapsed: '·',
bitrate: '·',
health: 100,
audio: false,
};
}
// Resolve a node_id to a friendly hostname + online state from the cluster
// snapshot (ZAMPP_DATA.NODES, refreshed by the admin/cluster polls). Recorders
// group under their physical node; an offline node greys its whole group.
function _nodeMeta(nodeId) {
const nodes = window.ZAMPP_DATA?.NODES || [];
const n = nodes.find(x => x.id === nodeId || x.dbId === nodeId);
if (!n) return { hostname: nodeId ? nodeId.slice(0, 8) : 'unassigned', online: false };
const lastSeen = n.last_seen_at || n.last_seen;
const online = (n.status === 'online') ||
(lastSeen ? (Date.now() - new Date(lastSeen).getTime() < 90000) : false);
return { hostname: n.hostname || (nodeId ? nodeId.slice(0, 8) : 'node'), online };
}
function Recorders({ navigate, onNew }) {
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
// Per-recorder config editor (codec / growing / label). Null = closed.
const [configRecorder, setConfigRecorder] = React.useState(null);
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/recorders')
@ -653,7 +815,7 @@ function Recorders({ navigate, onNew }) {
React.useEffect(() => {
refresh();
const id = setInterval(refresh, 10000);
// Any screen that creates/starts/stops/deletes a recorder dispatches
// Any screen that enables/disables/records a recorder dispatches
// df:recorders-changed; refresh immediately instead of waiting for the tick.
const onChange = () => refresh();
window.addEventListener('df:recorders-changed', onChange);
@ -665,12 +827,35 @@ function Recorders({ navigate, onNew }) {
const liveCount = recorders.filter(r => r.status === 'recording').length;
const errCount = recorders.filter(r => r.status === 'error').length;
const enabledCount = recorders.filter(r => r.enabled).length;
// Group recorders by physical node. Recorders are hardware: one row per
// (node, port). Each group is sorted by capture-port index for a stable,
// physical layout. Network/legacy recorders (no node) fall into 'unassigned'.
const groups = React.useMemo(() => {
const byNode = new Map();
for (const r of recorders) {
const key = r.nodeId || '__unassigned__';
if (!byNode.has(key)) byNode.set(key, []);
byNode.get(key).push(r);
}
const out = [];
for (const [nodeId, list] of byNode) {
list.sort((a, b) => (a.deviceIndex ?? 999) - (b.deviceIndex ?? 999));
const meta = nodeId === '__unassigned__'
? { hostname: 'Network / unassigned', online: true }
: _nodeMeta(nodeId);
out.push({ nodeId, meta, list });
}
out.sort((a, b) => a.meta.hostname.localeCompare(b.meta.hostname));
return out;
}, [recorders]);
return (
<div className="page">
<div className="page-header">
<h1>Recorders</h1>
<span className="subtitle">Live ingest from SRT, RTMP, and SDI sources</span>
<span className="subtitle">Physical capture ports one per SDI / Deltacast input</span>
<div className="spacer" />
{(liveCount > 0 || errCount > 0) && (
<div className="status-pip">
@ -678,26 +863,50 @@ function Recorders({ navigate, onNew }) {
<span>{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}</span>
</div>
)}
<span className="badge neutral" title="Enabled recorders have a live standby sidecar">{enabledCount} enabled</span>
<button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
<button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>
</div>
<div className="page-body">
{recorders.length === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
No recorders configured.
<div style={{ marginTop: 12 }}><button className="btn primary" onClick={onNew}><Icon name="plus" />Add recorder</button></div>
No capture hardware discovered yet.
<div style={{ marginTop: 8, fontSize: 12 }}>
Recorders are auto-detected from each node's SDI / Deltacast ports as it heartbeats.
</div>
</div>
) : (
<div className="recorders-list">
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
</div>
groups.map(g => (
<div key={g.nodeId} className="recorder-node-group" style={{ marginBottom: 18, opacity: g.meta.online ? 1 : 0.55 }}>
<div className="recorder-node-head" style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 2px 8px' }}>
<Icon name="server" size={13} style={{ opacity: 0.7 }} />
<span style={{ fontWeight: 600, fontSize: 13 }}>{g.meta.hostname}</span>
<span className={'badge ' + (g.meta.online ? 'success' : 'neutral')}>
{g.meta.online ? 'online' : 'offline'}
</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{g.list.length} ports</span>
</div>
<div className="recorders-list">
{g.list.map(r => (
<RecorderRow key={r.id} recorder={r} nodeOnline={g.meta.online}
onRefresh={refresh} onConfigure={() => setConfigRecorder(r)} />
))}
</div>
</div>
))
)}
</div>
{configRecorder && (
<RecorderConfigModal
recorder={configRecorder}
onClose={() => setConfigRecorder(null)}
onSaved={() => { setConfigRecorder(null); refresh(); window.dispatchEvent(new CustomEvent('df:recorders-changed')); }}
/>
)}
</div>
);
}
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOnline }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [recorder, setRecorder] = React.useState(initialRecorder);
const [pending, setPending] = React.useState(false);
@ -797,44 +1006,58 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
};
const handleDelete = async () => {
if (!(await confirm({ title: 'Delete recorder?', message: 'Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.' }))) return;
window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
const isEnabled = recorder.enabled === true;
const offline = nodeOnline === false;
// Enable = bring up the persistent standby sidecar (ready to record).
// Disable = tear it down, freeing the capture port. Recorders are NEVER
// deleted they're physical ports. Disable is the teardown action.
const setEnabled = (next) => {
if (pending) return;
setPending(true); setErr(null);
const ep = next ? 'enable' : 'disable';
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + ep, { method: 'POST' })
.then(() => {
setPending(false);
onRefresh();
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
window.dispatchEvent(new CustomEvent('df:assets-changed'));
})
.catch(e => setErr(e.message || 'Delete failed'));
.catch(e => { setPending(false); setErr(e.message || (ep + ' failed')); });
};
return (
<div className={'recorder-row ' + recorder.status}>
<div className={'recorder-row ' + recorder.status + (isEnabled ? '' : ' is-disabled')}>
{confirmModal}
<div className="recorder-preview">
{isRec && recorder.live_asset_id
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
: isRec
? <LiveStrip seed={recorder.id.length * 3} count={6} />
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} /></div>}
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : (isEnabled ? 'video' : 'power')} size={20} style={{ opacity: 0.4 }} /></div>}
</div>
<div className="recorder-info">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.name}</span>
<span className={'badge ' + badgeForStatus(recorder.status)}>
<StatusDot status={recorder.status} /> {recorder.status.toUpperCase()}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.displayName}</span>
{recorder.label && (
<span className="mono" style={{ fontSize: 10.5, color: 'var(--text-4)' }} title="Hardware name">{recorder.hwName}</span>
)}
{isRec
? <span className="badge live"><StatusDot status="recording" /> RECORDING</span>
: isEnabled
? <span className="badge success">ENABLED</span>
: <span className="badge neutral">DISABLED</span>}
<span className="badge outline">{recorder.source}</span>
{recorder.capturePort && (
<span className="badge outline" title="Capture port" style={{ background: 'rgba(74,158,255,0.12)', borderColor: 'rgba(74,158,255,0.4)', color: 'var(--accent)' }}>
<Icon name="signal" size={10} style={{ marginRight: 4, verticalAlign: -1 }} />{recorder.capturePort}
</span>
)}
{recorder.growing && <span className="badge accent" title="Growing-file (edit-while-record)">GROWING</span>}
</div>
<div className="recorder-sub mono">{recorder.url}</div>
<div className="recorder-sub">
<span>{recorder.codec}</span><span>·</span>
<span>{recorder.res}</span>
<span>{recorder.res}</span><span>·</span>
<span>{recorder.framerate}</span>
</div>
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
{liveStatus?.lastError && isRec && (
@ -855,7 +1078,8 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
</div>
</div>
<div className="recorder-actions">
{!isRec && (
{/* Record controls only when ENABLED (standby sidecar up) and not recording. */}
{isEnabled && !isRec && (
<>
{PROJECTS.length > 0 && (
<select
@ -863,7 +1087,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
value={takeProjectId}
onChange={e => setTakeProjectId(e.target.value)}
disabled={pending}
style={{ width: 160, padding: '5px 8px', fontSize: 12, appearance: 'auto' }}
style={{ width: 150, padding: '5px 8px', fontSize: 12, appearance: 'auto' }}
title="Project clips go to"
>
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
@ -877,30 +1101,42 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
disabled={pending}
maxLength={80}
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
style={{ width: 160, padding: '5px 8px', fontSize: 12 }}
style={{ width: 150, padding: '5px 8px', fontSize: 12 }}
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
/>
</>
)}
{isRec
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" />Stop</>}
</button>
: <button className="btn subtle sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
</button>}
<button className="icon-btn" onClick={handleDelete} title="Delete recorder" aria-label="Delete recorder" style={{ color: 'var(--text-3)' }}>
<Icon name="x" />
{isRec ? (
<button className="btn danger sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" />Stop</>}
</button>
) : isEnabled ? (
<button className="btn subtle sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
</button>
) : null}
{/* Enable / Disable — the lifecycle control. Hidden while recording. */}
{!isRec && (
isEnabled
? <button className="btn ghost sm" onClick={() => setEnabled(false)} disabled={pending} title="Stop the standby sidecar and free the capture port">
<Icon name="power" size={12} />Disable
</button>
: <button className="btn primary sm" onClick={() => setEnabled(true)} disabled={pending || offline}
title={offline ? 'Node offline — cannot enable' : 'Start the standby sidecar so this port is ready to record'}>
<Icon name="power" size={12} />Enable
</button>
)}
<button className="icon-btn" onClick={onConfigure} title="Configure recorder" aria-label="Configure recorder" style={{ color: 'var(--text-3)' }}>
<Icon name="settings" />
</button>
</div>
</div>
);
}
function badgeForStatus(s) {
return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
}
/* ===== Capture ===== */
function _captureSignalChip(sig) {