dragonflight/services/web-ui/public/modal-new-recorder.jsx
opencode 04ce096e67 chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
  validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
  production React UMD instead of dev builds + in-browser Babel (#139,
  #122)
- Search wrapper gets role=search; global search input gets aria-label,
  role=combobox, aria-controls/aria-expanded/aria-activedescendant
  wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
  mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
  flex container — switched to flex:1 + min-height:0 (#131, #132,
  editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
  the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
  data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
  window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
  uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)

Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
  Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
  of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
  tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
  transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
  detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
  tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
  create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
  private + loopback hosts for non-admins, denies common service
  ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
  rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
  survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
  server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
  (ampp_sync_status / attempts / next_attempt_at + scheduler retry
  loop with exponential backoff) (#77)

Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)

Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
  Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
  migration tool to v1.3
2026-05-27 02:06:14 +00:00

372 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// modal-new-recorder.jsx — New Recorder dialog (SRT / RTMP / SDI)
function ProbeResult({ result }) {
if (!result.ok) {
return (
<div style={{ marginTop: 6, padding: '8px 10px', background: 'var(--danger-soft)', border: '1px solid var(--danger)', borderRadius: 5, fontSize: 11.5, color: 'var(--danger)' }}>
Probe failed: {result.error}
</div>
);
}
const d = result.data || {};
const entries = Object.entries(d).filter(([, v]) => v !== null && typeof v !== 'object');
if (entries.length === 0) {
return (
<div style={{ marginTop: 6, padding: '8px 10px', background: 'var(--success-soft)', border: '1px solid var(--success)', borderRadius: 5, fontSize: 11.5, color: 'var(--success)' }}>
Source reachable
</div>
);
}
return (
<div style={{ marginTop: 6, padding: '10px 12px', background: 'var(--bg-2)', border: '1px solid var(--success)', borderRadius: 5, fontSize: 11.5 }}>
{entries.map(([k, v]) => (
<div key={k} style={{ display: 'flex', gap: 8, padding: '2px 0' }}>
<span style={{ color: 'var(--text-3)', minWidth: 100, flexShrink: 0 }}>{k}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{String(v)}</span>
</div>
))}
</div>
);
}
function NewRecorderModal({ open, onClose }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const NODES = window.ZAMPP_DATA?.NODES || [];
const [name, setName] = React.useState('');
const [sourceType, setSourceType] = React.useState('SRT');
const [srtUrl, setSrtUrl] = React.useState('srt://10.0.4.18:4200');
const [rtmpUrl, setRtmpUrl] = React.useState('rtmp://stream.local/live/cam_a');
const [sdiDeviceIdx, setSdiDeviceIdx] = React.useState(0);
const [sdiNodeId, setSdiNodeId] = React.useState(() => {
const n = NODES[0];
return n ? (n.id || n.hostname || '') : '';
});
const [sdiDevices, setSdiDevices] = React.useState(null);
const [recTab, setRecTab] = React.useState('video');
const [recCodec, setRecCodec] = React.useState('prores_hq');
const [recContainer, setRecContainer] = React.useState('mov');
const [proxyOn, setProxyOn] = React.useState(true);
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [submitting, setSubmitting] = React.useState(false);
const [submitErr, setSubmitErr] = React.useState(null);
const [probing, setProbing] = React.useState(false);
const [probeResult, setProbeResult] = React.useState(null);
React.useEffect(() => {
if (sourceType !== 'SDI' || sdiDevices !== null) return;
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
.then(d => setSdiDevices(Array.isArray(d) ? d : []))
.catch(() => setSdiDevices([]));
}, [sourceType]);
React.useEffect(() => { setProbeResult(null); }, [sourceType, srtUrl, rtmpUrl]);
const handleProbe = () => {
setProbing(true);
setProbeResult(null);
const body = sourceType === 'SRT'
? { source_type: 'srt', url: srtUrl }
: { source_type: 'rtmp', url: rtmpUrl };
window.ZAMPP_API.fetch('/recorders/probe', { method: 'POST', body: JSON.stringify(body) })
.then(r => { setProbing(false); setProbeResult({ ok: true, data: r }); })
.catch(e => { setProbing(false); setProbeResult({ ok: false, error: e.message }); });
};
const handleCreate = () => {
if (!name.trim()) { setSubmitErr('Recorder name is required.'); return; }
if (sourceType === 'SDI' && !sdiNodeId) { setSubmitErr('Select a capture node for SDI.'); return; }
setSubmitting(true);
setSubmitErr(null);
const body = {
name: name.trim(),
source_type: sourceType.toLowerCase(),
project_id: projectId || undefined,
generate_proxy: proxyOn,
recording_codec: recCodec,
recording_container: recContainer,
};
if (sourceType === 'SRT') {
body.source_config = { url: srtUrl };
} else if (sourceType === 'RTMP') {
body.source_config = { url: rtmpUrl };
} else {
// SDI: device_index and node_id are top-level fields
body.source_config = {};
body.device_index = sdiDeviceIdx;
body.node_id = sdiNodeId || undefined;
}
window.ZAMPP_API.fetch('/recorders', { method: 'POST', body: JSON.stringify(body) })
.then(() => {
setSubmitting(false);
// Recorders list listens for this and re-fetches; otherwise the
// operator has to wait for the next 10s poll tick to see the new row.
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
onClose();
})
.catch(e => { setSubmitting(false); setSubmitErr(e.message || 'Failed to create recorder'); });
};
if (!open) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>New recorder</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>Configure source, codec, and destination</div>
</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Recorder name</label>
<input className="field-input" placeholder="e.g. Studio A Stage Cam"
value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="field">
<label className="field-label">Source type</label>
<div className="source-type-grid">
{[
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport — pull caller', icon: 'signal' },
{ id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' },
{ id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' },
].map(t => (
<button key={t.id}
className={`source-type-card ${sourceType === t.id ? 'active' : ''}`}
onClick={() => setSourceType(t.id)}>
<div className="source-type-icon"><Icon name={t.icon} size={16} /></div>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{t.label}</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 2 }}>{t.desc}</div>
</div>
</button>
))}
</div>
</div>
{sourceType === 'SRT' && (
<div className="field">
<label className="field-label">Source URL</label>
<div style={{ display: 'flex', gap: 6 }}>
<input className="field-input mono" placeholder="srt://192.168.1.100:4200"
value={srtUrl} onChange={e => setSrtUrl(e.target.value)} style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={handleProbe} disabled={probing}
style={{ flexShrink: 0, minWidth: 64 }}>
{probing ? '…' : 'Probe'}
</button>
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
Recorder connects out to this URL (caller mode).
</div>
{probeResult && <ProbeResult result={probeResult} />}
</div>
)}
{sourceType === 'RTMP' && (
<div className="field">
<label className="field-label">Source URL</label>
<div style={{ display: 'flex', gap: 6 }}>
<input className="field-input mono" placeholder="rtmp://server/live/streamkey"
value={rtmpUrl} onChange={e => setRtmpUrl(e.target.value)} style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={handleProbe} disabled={probing}
style={{ flexShrink: 0, minWidth: 64 }}>
{probing ? '…' : 'Probe'}
</button>
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
Recorder pulls this RTMP stream.
</div>
{probeResult && <ProbeResult result={probeResult} />}
</div>
)}
{sourceType === 'SDI' && (
<div className="field">
<label className="field-label">Capture device</label>
{sdiDevices === null && (
<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>
)}
{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>
)}
</div>
)}
<div className="modal-section">
<div className="modal-section-head">
<span>Master recording</span>
<span style={{ flex: 1 }} />
<div className="tab-group">
{['video', 'audio', 'container'].map(t => (
<button key={t} className={recTab === t ? 'active' : ''} onClick={() => setRecTab(t)}>
{t[0].toUpperCase() + t.slice(1)}
</button>
))}
</div>
</div>
<div className="modal-section-body">
{recTab === 'video' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">Video codec</label>
<select className="field-input" value={recCodec} onChange={e => setRecCodec(e.target.value)} style={{ appearance: 'auto' }}>
<option value="prores_4444xq">ProRes 4444 XQ</option>
<option value="prores_4444">ProRes 4444</option>
<option value="prores_hq">ProRes 422 HQ</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="libx264">H.264 (x264)</option>
<option value="libx265">H.265 / HEVC (x265)</option>
<option value="dnxhd">DNxHD 185x</option>
<option value="dnxhr_hq">DNxHR HQ</option>
<option value="xdcam_hd422">XDCAM HD422</option>
</select>
</div>
<Field label="Resolution" value="Source (native)" select />
<Field label="Color space" value="Rec. 709" select />
<Field label="Bit depth" value="10-bit" select />
</div>
)}
{recTab === 'audio' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<Field label="Audio codec" value="PCM" select />
<Field label="Sample rate" value="48 kHz" select />
<Field label="Channels" value="2.0 stereo" select />
<Field label="Bit depth" value="24-bit" select />
</div>
)}
{recTab === 'container' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">Container</label>
<select className="field-input" value={recContainer} onChange={e => setRecContainer(e.target.value)} style={{ appearance: 'auto' }}>
<option value="mov">MOV (QuickTime)</option>
<option value="mxf">MXF (SMPTE)</option>
<option value="mkv">MKV (Matroska)</option>
<option value="mp4">MP4</option>
</select>
</div>
<Field label="Segment" value="None (single file)" select />
</div>
)}
</div>
</div>
<div className="modal-toggle-row">
<label className="switch">
<input type="checkbox" checked={proxyOn} onChange={e => setProxyOn(e.target.checked)} />
<span className="switch-track"><span className="switch-knob" /></span>
</label>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>Generate proxy</div>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
SDI sources record proxy in parallel. Network sources generate proxy after stop.
</div>
</div>
</div>
{proxyOn && (
<div className="modal-section">
<div className="modal-section-head"><span>Proxy</span></div>
<div className="modal-section-body">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
{['H.264', '2 Mbps', 'MP4', '1920×1080', 'AAC 128 kbps'].map(tag => (
<span key={tag} className="mono" style={{ background: 'var(--bg-3)', borderRadius: 4, padding: '2px 8px', fontSize: 12 }}>{tag}</span>
))}
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Fixed proxy profile not configurable.</div>
</div>
</div>
)}
<div className="modal-section">
<div className="modal-section-head"><span>Destination</span></div>
<div className="modal-section-body">
<div className="field">
<label className="field-label">Project</label>
<select className="field-input" value={projectId}
onChange={e => setProjectId(e.target.value)} style={{ appearance: 'auto' }}>
{PROJECTS.length === 0
? <option value="">No projects</option>
: PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
</div>
</div>
{submitErr && (
<div style={{
padding: '10px 14px', background: 'var(--danger-soft)',
border: '1px solid var(--danger)', borderRadius: 6,
fontSize: 12.5, color: 'var(--danger)', marginTop: 4,
}}>{submitErr}</div>
)}
</div>
<div className="modal-foot">
<button className="btn ghost" onClick={onClose}>Cancel</button>
<span style={{ flex: 1 }} />
<button className="btn primary" onClick={handleCreate} disabled={submitting}>
{submitting ? 'Creating…' : 'Create recorder'}
</button>
</div>
</div>
</div>
);
}
window.NewRecorderModal = NewRecorderModal;