dragonflight/services/web-ui/public/modal-new-recorder.jsx
Zac Gaetano 6a1d271576 feat(ui): polish round 2 — live refresh, schedule calendar, jobs times, real sidebar user
- recorders: dispatch df:recorders-changed on create/start/stop/delete so the
  list updates immediately instead of waiting for the 10s poll tick
- library: poll every 4s while any asset is live/processing (15s otherwise) and
  listen for df:assets-changed so a stopped recorder's LIVE badge drops and
  the thumbnail appears without a manual refresh
- auth: synthetic /auth/me (AUTH_ENABLED=false) now uses LOCAL_OPERATOR / USER /
  USERNAME instead of hardcoding "Admin", and flags synthetic:true
- shell: Sidebar takes `me` as a prop, drops the misleading "Admin" fallback,
  and surfaces an "auth off" hint when the response is synthetic
- jobs: replace the always-empty ETA column with a Time column that shows
  queued/started/done/failed N ago (full timestamp on hover); widen column
- schedule: new month-calendar view (default) with events plotted on day cells
  by status; clicking a day pre-fills the new-schedule modal with a 30-min
  window on that day; List view kept behind a toggle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:52:04 -04: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" 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;