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:
parent
9f2eac7b61
commit
de509c66ab
5 changed files with 500 additions and 53 deletions
|
|
@ -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);
|
||||||
|
|
@ -242,10 +242,65 @@ router.post('/heartbeat', async (req, res, next) => {
|
||||||
metrics != null ? JSON.stringify(metrics) : null,
|
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]);
|
res.json(r.rows[0]);
|
||||||
} catch (err) { next(err); }
|
} 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) => {
|
router.get('/devices/blackmagic/signal', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const nodesResult = await pool.query(
|
const nodesResult = await pool.query(
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ const RECORDER_FIELDS = [
|
||||||
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
|
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
|
||||||
'proxy_container',
|
'proxy_container',
|
||||||
'project_id', 'node_id', 'device_index',
|
'project_id', 'node_id', 'device_index',
|
||||||
'growing_enabled',
|
'growing_enabled', 'label',
|
||||||
];
|
];
|
||||||
|
|
||||||
function pickRecorderFields(body) {
|
function pickRecorderFields(body) {
|
||||||
|
|
@ -329,6 +329,31 @@ async function ensureStandbySidecar(recorder) {
|
||||||
return { ok: true, containerId };
|
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
|
// 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
|
// finalize its master. The asset row was pre-created at start with
|
||||||
// status='live' (display_name = current_session_id); the ingest/finalize step
|
// 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
|
// GET /:id - Get single recorder
|
||||||
router.get('/:id', async (req, res, next) => {
|
router.get('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -451,6 +451,10 @@ async function handleSidecarStart(body, res) {
|
||||||
gpuUuid = null,
|
gpuUuid = null,
|
||||||
} = body;
|
} = 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`];
|
const binds = [`${LIVE_DIR}:/live`];
|
||||||
// Always mount /dev/shm so the sidecar can access framecache slots.
|
// Always mount /dev/shm so the sidecar can access framecache slots.
|
||||||
if (fs.existsSync('/dev/shm')) binds.push('/dev/shm:/dev/shm');
|
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).
|
// 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 —
|
// 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.
|
// 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) {
|
async function handleSidecarStandby(body, res) {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
|
|
@ -707,6 +742,10 @@ async function handleSidecarStandby(body, res) {
|
||||||
gpuUuid = null,
|
gpuUuid = null,
|
||||||
} = body;
|
} = 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`];
|
const binds = [`${LIVE_DIR}:/live`];
|
||||||
if (fs.existsSync('/dev/shm')) binds.push('/dev/shm:/dev/shm');
|
if (fs.existsSync('/dev/shm')) binds.push('/dev/shm:/dev/shm');
|
||||||
if (sourceType === 'sdi' || sourceType === 'blackmagic') binds.unshift('/dev/blackmagic:/dev/blackmagic');
|
if (sourceType === 'sdi' || sourceType === 'blackmagic') binds.unshift('/dev/blackmagic:/dev/blackmagic');
|
||||||
|
|
|
||||||
|
|
@ -601,39 +601,201 @@ function HlsPreviewUrl({ url }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Recorders ===== */
|
/* ===== 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 (disable→enable) 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) {
|
function _normRecorder(r) {
|
||||||
const cfg = r.source_config || {};
|
const cfg = r.source_config || {};
|
||||||
// Surface the capture port for SDI / Deltacast recorders so the recorder card
|
// Surface the physical capture port. Recorders are now hardware-bound: one row
|
||||||
// can show which physical input the recorder is bound to. For Deltacast,
|
// per (node, port), so device_index is authoritative. For Deltacast cfg.port,
|
||||||
// cfg.port is the bridge port index (0-7). For Blackmagic SDI, cfg.device
|
// for Blackmagic SDI cfg.device (/dev/blackmagic/io0) — slice the trailing idx.
|
||||||
// is something like /dev/blackmagic/dv0 — we slice off the trailing index.
|
let portIdx = r.device_index;
|
||||||
let capturePort = null;
|
let capturePort = null;
|
||||||
if (r.source_type === 'deltacast') {
|
if (r.source_type === 'deltacast') {
|
||||||
capturePort = cfg.port != null ? `Port ${cfg.port}` : null;
|
portIdx = portIdx ?? cfg.port;
|
||||||
} else if (r.source_type === 'sdi') {
|
capturePort = portIdx != null ? `Port ${portIdx}` : null;
|
||||||
const dev = cfg.device || '';
|
} else if (r.source_type === 'sdi' || r.source_type === 'blackmagic') {
|
||||||
const m = dev.match(/(\d+)$/);
|
if (portIdx == null) {
|
||||||
if (m) capturePort = `SDI ${m[1]}`;
|
const m = String(cfg.device || '').match(/(\d+)$/);
|
||||||
|
if (m) portIdx = parseInt(m[1], 10);
|
||||||
|
}
|
||||||
|
capturePort = portIdx != null ? `SDI ${portIdx}` : null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...r,
|
...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 || '·',
|
source: r.source_type || '·',
|
||||||
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·',
|
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·',
|
||||||
codec: r.recording_codec || '·',
|
codec: r.recording_codec || '·',
|
||||||
res: r.recording_resolution || '·',
|
res: r.recording_resolution || '·',
|
||||||
framerate: r.recording_framerate || 'native',
|
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',
|
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
|
||||||
|
deviceIndex: portIdx ?? null,
|
||||||
capturePort,
|
capturePort,
|
||||||
previewUrl: r.preview_url || null,
|
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 }) {
|
function Recorders({ navigate, onNew }) {
|
||||||
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
|
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(() => {
|
const refresh = React.useCallback(() => {
|
||||||
window.ZAMPP_API.fetch('/recorders')
|
window.ZAMPP_API.fetch('/recorders')
|
||||||
|
|
@ -653,7 +815,7 @@ function Recorders({ navigate, onNew }) {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
const id = setInterval(refresh, 10000);
|
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.
|
// df:recorders-changed; refresh immediately instead of waiting for the tick.
|
||||||
const onChange = () => refresh();
|
const onChange = () => refresh();
|
||||||
window.addEventListener('df:recorders-changed', onChange);
|
window.addEventListener('df:recorders-changed', onChange);
|
||||||
|
|
@ -665,12 +827,35 @@ function Recorders({ navigate, onNew }) {
|
||||||
|
|
||||||
const liveCount = recorders.filter(r => r.status === 'recording').length;
|
const liveCount = recorders.filter(r => r.status === 'recording').length;
|
||||||
const errCount = recorders.filter(r => r.status === 'error').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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Recorders</h1>
|
<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" />
|
<div className="spacer" />
|
||||||
{(liveCount > 0 || errCount > 0) && (
|
{(liveCount > 0 || errCount > 0) && (
|
||||||
<div className="status-pip">
|
<div className="status-pip">
|
||||||
|
|
@ -678,26 +863,50 @@ function Recorders({ navigate, onNew }) {
|
||||||
<span>{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}</span>
|
<span>{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}</span>
|
||||||
</div>
|
</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 ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
|
||||||
<button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="page-body">
|
<div className="page-body">
|
||||||
{recorders.length === 0 ? (
|
{recorders.length === 0 ? (
|
||||||
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
|
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
|
||||||
No recorders configured.
|
No capture hardware discovered yet.
|
||||||
<div style={{ marginTop: 12 }}><button className="btn primary" onClick={onNew}><Icon name="plus" />Add recorder</button></div>
|
<div style={{ marginTop: 8, fontSize: 12 }}>
|
||||||
|
Recorders are auto-detected from each node's SDI / Deltacast ports as it heartbeats.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="recorders-list">
|
groups.map(g => (
|
||||||
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
|
<div key={g.nodeId} className="recorder-node-group" style={{ marginBottom: 18, opacity: g.meta.online ? 1 : 0.55 }}>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
{configRecorder && (
|
||||||
|
<RecorderConfigModal
|
||||||
|
recorder={configRecorder}
|
||||||
|
onClose={() => setConfigRecorder(null)}
|
||||||
|
onSaved={() => { setConfigRecorder(null); refresh(); window.dispatchEvent(new CustomEvent('df:recorders-changed')); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
function RecorderRow({ recorder: initialRecorder, onRefresh, onConfigure, nodeOnline }) {
|
||||||
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
|
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
|
||||||
const [recorder, setRecorder] = React.useState(initialRecorder);
|
const [recorder, setRecorder] = React.useState(initialRecorder);
|
||||||
const [pending, setPending] = React.useState(false);
|
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); });
|
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const isEnabled = recorder.enabled === true;
|
||||||
if (!(await confirm({ title: 'Delete recorder?', message: 'Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.' }))) return;
|
const offline = nodeOnline === false;
|
||||||
window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
|
|
||||||
|
// 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(() => {
|
.then(() => {
|
||||||
|
setPending(false);
|
||||||
onRefresh();
|
onRefresh();
|
||||||
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
|
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 (
|
return (
|
||||||
<div className={'recorder-row ' + recorder.status}>
|
<div className={'recorder-row ' + recorder.status + (isEnabled ? '' : ' is-disabled')}>
|
||||||
{confirmModal}
|
{confirmModal}
|
||||||
<div className="recorder-preview">
|
<div className="recorder-preview">
|
||||||
{isRec && recorder.live_asset_id
|
{isRec && recorder.live_asset_id
|
||||||
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
|
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
|
||||||
: isRec
|
: isRec
|
||||||
? <LiveStrip seed={recorder.id.length * 3} count={6} />
|
? <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>
|
||||||
<div className="recorder-info">
|
<div className="recorder-info">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.name}</span>
|
<span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.displayName}</span>
|
||||||
<span className={'badge ' + badgeForStatus(recorder.status)}>
|
{recorder.label && (
|
||||||
<StatusDot status={recorder.status} /> {recorder.status.toUpperCase()}
|
<span className="mono" style={{ fontSize: 10.5, color: 'var(--text-4)' }} title="Hardware name">{recorder.hwName}</span>
|
||||||
</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>
|
<span className="badge outline">{recorder.source}</span>
|
||||||
{recorder.capturePort && (
|
{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)' }}>
|
<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}
|
<Icon name="signal" size={10} style={{ marginRight: 4, verticalAlign: -1 }} />{recorder.capturePort}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{recorder.growing && <span className="badge accent" title="Growing-file (edit-while-record)">GROWING</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="recorder-sub mono">{recorder.url}</div>
|
|
||||||
<div className="recorder-sub">
|
<div className="recorder-sub">
|
||||||
<span>{recorder.codec}</span><span>·</span>
|
<span>{recorder.codec}</span><span>·</span>
|
||||||
<span>{recorder.res}</span>
|
<span>{recorder.res}</span><span>·</span>
|
||||||
|
<span>{recorder.framerate}</span>
|
||||||
</div>
|
</div>
|
||||||
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
|
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
|
||||||
{liveStatus?.lastError && isRec && (
|
{liveStatus?.lastError && isRec && (
|
||||||
|
|
@ -855,7 +1078,8 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="recorder-actions">
|
<div className="recorder-actions">
|
||||||
{!isRec && (
|
{/* Record controls only when ENABLED (standby sidecar up) and not recording. */}
|
||||||
|
{isEnabled && !isRec && (
|
||||||
<>
|
<>
|
||||||
{PROJECTS.length > 0 && (
|
{PROJECTS.length > 0 && (
|
||||||
<select
|
<select
|
||||||
|
|
@ -863,7 +1087,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
value={takeProjectId}
|
value={takeProjectId}
|
||||||
onChange={e => setTakeProjectId(e.target.value)}
|
onChange={e => setTakeProjectId(e.target.value)}
|
||||||
disabled={pending}
|
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"
|
title="Project clips go to"
|
||||||
>
|
>
|
||||||
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
|
@ -877,30 +1101,42 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
maxLength={80}
|
maxLength={80}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
|
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."
|
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isRec
|
|
||||||
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
|
{isRec ? (
|
||||||
{pending ? '…' : <><span className="rec-dot" />Stop</>}
|
<button className="btn danger sm" onClick={toggle} disabled={pending}>
|
||||||
</button>
|
{pending ? '…' : <><span className="rec-dot" />Stop</>}
|
||||||
: <button className="btn subtle sm" onClick={toggle} disabled={pending}>
|
</button>
|
||||||
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
|
) : isEnabled ? (
|
||||||
</button>}
|
<button className="btn subtle sm" onClick={toggle} disabled={pending}>
|
||||||
<button className="icon-btn" onClick={handleDelete} title="Delete recorder" aria-label="Delete recorder" style={{ color: 'var(--text-3)' }}>
|
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
|
||||||
<Icon name="x" />
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeForStatus(s) {
|
|
||||||
return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Capture ===== */
|
/* ===== Capture ===== */
|
||||||
|
|
||||||
function _captureSignalChip(sig) {
|
function _captureSignalChip(sig) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue