dragonflight/services/web-ui/public/screens-ingest.jsx
zgaetano 7e64675aa5 fix: settings S3 surfaces fetch errors; recorder signal dot pulses
- screens-admin.jsx S3SettingsCard: when /settings/s3 fails, log to
  console and surface the message in the existing SettingsMsg banner
  instead of silently returning empty fields. Also logs the response
  payload on success so the next "endpoint blank" report is easier to
  diagnose. (closes part of #15)
- screens-ingest.jsx recorder row: wrap the signal value in a dot+text
  pair; add CSS so the dot pulses green when status=receiving and
  matches the value color otherwise. The pulse is the kind of cue the
  Live signal column was missing per #2.
2026-05-23 13:19:48 -04:00

988 lines
41 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.

// screens-ingest.jsx — Upload, Recorders, Capture, Monitors
/* ===== Upload helpers ===== */
const _SIMPLE_MAX = 50 * 1024 * 1024; // 50 MB → simple upload
const _PART_SIZE = 10 * 1024 * 1024; // 10 MB chunks for multipart
function _xhrPost(url, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded, e.total); };
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
} else {
let msg = xhr.status + ' ' + xhr.statusText;
try { const j = JSON.parse(xhr.responseText); msg = j.error || j.message || msg; } catch {}
reject(new Error(msg));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.open('POST', url);
xhr.send(formData);
});
}
async function _uploadFile(file, projectId, onProgress) {
const mime = file.type || 'application/octet-stream';
if (file.size <= _SIMPLE_MAX) {
const fd = new FormData();
fd.append('file', file, file.name);
fd.append('filename', file.name);
fd.append('projectId', projectId);
fd.append('contentType', mime);
return _xhrPost('/api/v1/upload/simple', fd,
(loaded, total) => onProgress(Math.round((loaded / total) * 100)));
}
// — Multipart —
const init = await window.ZAMPP_API.fetch('/upload/init', {
method: 'POST',
body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
});
const { assetId, uploadId, key } = init;
const totalParts = Math.ceil(file.size / _PART_SIZE);
const parts = [];
for (let i = 0; i < totalParts; i++) {
const chunk = file.slice(i * _PART_SIZE, Math.min((i + 1) * _PART_SIZE, file.size));
const fd = new FormData();
fd.append('file', chunk, file.name);
fd.append('uploadId', uploadId);
fd.append('key', key);
fd.append('partNumber', String(i + 1));
const partRes = await _xhrPost('/api/v1/upload/part', fd,
(loaded, total) => onProgress(Math.round(((i + loaded / total) / totalParts) * 100)));
parts.push({ PartNumber: i + 1, ETag: partRes.etag || partRes.ETag });
}
await window.ZAMPP_API.fetch('/upload/complete', {
method: 'POST',
body: JSON.stringify({ uploadId, key, assetId, parts }),
});
return { id: assetId };
}
/* ===== Upload ===== */
function Upload({ navigate }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [files, setFiles] = React.useState([]);
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const updateFile = React.useCallback((id, patch) => {
setFiles(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
}, []);
const startUpload = React.useCallback((entry, pid) => {
_uploadFile(entry.file, pid, (pct) => updateFile(entry.id, { progress: pct }))
.then(() => updateFile(entry.id, { status: 'done', progress: 100 }))
.catch(e => updateFile(entry.id, { status: 'error', progress: 0, error: e.message }));
}, [updateFile]);
const handleDrop = React.useCallback((e) => {
e.preventDefault();
const dropped = Array.from(e.dataTransfer ? e.dataTransfer.files : e.target.files);
const pid = projectId || PROJECTS[0]?.id || '';
const newEntries = dropped.map((f, i) => ({
id: Date.now() + i,
name: f.name,
size: window.ZAMPP_API.fmtSize(f.size),
file: f,
progress: 0,
status: 'uploading',
error: null,
}));
setFiles(prev => [...prev, ...newEntries]);
newEntries.forEach(entry => startUpload(entry, pid));
}, [projectId, startUpload]);
return (
<div className="page">
<div className="page-header">
<h1>Upload</h1>
<span className="subtitle">Drop video, audio, or stills we proxy and index automatically.</span>
</div>
<div className="page-body">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
<div>
<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 className="dropzone"
onDrop={handleDrop}
onDragOver={e => e.preventDefault()}
onClick={() => {
const inp = document.createElement('input');
inp.type = 'file'; inp.multiple = true;
inp.onchange = handleDrop;
inp.click();
}}>
<Icon name="upload" size={32} style={{ color: 'var(--text-3)' }} />
<div style={{ fontSize: 15, fontWeight: 500 }}>Drop files here or click to browse</div>
<div className="muted" style={{ fontSize: 12.5 }}>Video, audio, and image files</div>
<div className="dropzone-formats">
{['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f =>
<span key={f} className="badge outline">{f}</span>)}
</div>
</div>
{files.length > 0 && (
<div style={{ marginTop: 24 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
Queue <span className="badge neutral">{files.length}</span>
<span style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={() => setFiles(f => f.filter(x => x.status === 'uploading'))}>Clear done</button>
</div>
<div className="panel">
{files.map(f => (
<div key={f.id} className="upload-row">
<Icon name={f.name.match(/\.(wav|aif|mp3|aiff)$/i) ? 'audio' : 'video'} size={16}
style={{ color: 'var(--text-3)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ fontWeight: 500, fontSize: 12.5 }}>{f.name}</span>
<span className="muted" style={{ fontSize: 11, fontFamily: 'var(--font-mono)' }}>{f.size}</span>
</div>
<div style={{ marginTop: 6, height: 4, background: 'var(--bg-3)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{
width: f.progress + '%', height: '100%',
background: f.status === 'done' ? 'var(--success)' : f.status === 'error' ? 'var(--danger)' : 'var(--accent)',
transition: 'width 200ms',
}} />
</div>
{f.status === 'error' && (
<div style={{ marginTop: 3, fontSize: 11, color: 'var(--danger)' }}>{f.error}</div>
)}
</div>
<span className="mono" style={{
fontSize: 11.5, minWidth: 60, textAlign: 'right',
color: f.status === 'done' ? 'var(--success)' : f.status === 'error' ? 'var(--danger)' : 'var(--text-3)',
}}>
{f.status === 'done' ? '✓ done'
: f.status === 'error' ? '✗ failed'
: f.progress + '%'}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
/* ===== Live preview (HLS) ====================================
Shared by RecorderRow + MonitorTile. The capture container writes
HLS segments to /live/{assetId}/index.m3u8 (see capture-manager.js
and nginx.conf); we attach hls.js to a <video> when a recorder is
actively recording and has a live asset.
============================================================ */
function HlsPreview({ assetId, muted = true, controls = false, className }) {
const videoRef = React.useRef(null);
const [err, setErr] = React.useState(null);
React.useEffect(() => {
if (!assetId || !videoRef.current) return;
const url = '/live/' + assetId + '/index.m3u8';
const v = videoRef.current;
// Safari can play HLS natively; everything else needs hls.js.
if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = url;
const onErr = () => setErr('playback failed');
v.addEventListener('error', onErr);
return () => v.removeEventListener('error', onErr);
}
if (!window.Hls) { setErr('hls.js missing'); return; }
const hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });
hls.loadSource(url);
hls.attachMedia(v);
hls.on(window.Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setErr(data.details || 'hls error');
});
return () => { try { hls.destroy(); } catch (_) {} };
}, [assetId]);
return (
<div className={className} style={{ position: 'relative', width: '100%', height: '100%', background: '#000', overflow: 'hidden' }}>
<video
ref={videoRef}
autoPlay
playsInline
muted={muted}
controls={controls}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
{err && (
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
color: 'var(--text-3)', fontSize: 11, background: 'rgba(0,0,0,0.5)' }}>
{err}
</div>
)}
</div>
);
}
/* ===== Recorders ===== */
function _normRecorder(r) {
let elapsed = '—';
if (r.status === 'recording' && r.started_at) {
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
String(Math.floor((s % 3600) / 60)).padStart(2, '00') + ':' +
String(s % 60).padStart(2, '0');
}
const cfg = r.source_config || {};
return {
...r,
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 || '—',
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
elapsed,
bitrate: '—',
health: 100,
audio: false,
};
}
function Recorders({ navigate, onNew }) {
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/recorders')
.then(raw => {
const norm = (raw || []).map(_normRecorder);
window.ZAMPP_DATA.RECORDERS = norm;
setRecorders(norm);
})
.catch(() => {});
}, []);
React.useEffect(() => {
refresh();
const id = setInterval(refresh, 10000);
return () => clearInterval(id);
}, []);
const liveCount = recorders.filter(r => r.status === 'recording').length;
const errCount = recorders.filter(r => r.status === 'error').length;
return (
<div className="page">
<div className="page-header">
<h1>Recorders</h1>
<span className="subtitle">Live ingest from SRT, RTMP, and SDI sources</span>
<div className="spacer" />
{(liveCount > 0 || errCount > 0) && (
<div className="status-pip">
<span className="dot" />
<span>{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}</span>
</div>
)}
<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>
</div>
) : (
<div className="recorders-list">
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
</div>
)}
</div>
</div>
);
}
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
const [recorder, setRecorder] = React.useState(initialRecorder);
const [pending, setPending] = React.useState(false);
const [err, setErr] = React.useState(null);
const [liveStatus, setLiveStatus] = React.useState(null);
const [clipName, setClipName] = React.useState('');
const isRec = recorder.status === 'recording';
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
// Poll the status endpoint every 3s while recording for live feedback.
React.useEffect(() => {
if (!isRec) { setLiveStatus(null); return; }
const poll = () => {
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/status')
.then(s => setLiveStatus(s))
.catch(() => {});
};
poll();
const id = setInterval(poll, 3000);
return () => clearInterval(id);
}, [isRec, recorder.id]);
const displayElapsed = React.useMemo(() => {
if (liveStatus && liveStatus.duration != null) {
const d = Math.max(0, liveStatus.duration);
return String(Math.floor(d / 3600)).padStart(2, '0') + ':' +
String(Math.floor((d % 3600) / 60)).padStart(2, '0') + ':' +
String(d % 60).padStart(2, '0');
}
return recorder.elapsed;
}, [liveStatus, recorder.elapsed]);
const displaySignal = liveStatus
? (liveStatus.signal || '—')
: (isRec ? 'connecting…' : '—');
const signalColor = displaySignal === 'receiving' ? 'var(--success)'
: displaySignal === 'stopped' ? 'var(--danger)'
: 'var(--text-3)';
const toggle = () => {
if (pending) return;
const action = isRec ? 'stop' : 'start';
setPending(true);
setErr(null);
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
// Ship the operator-typed clip name on start; stop has no body.
const body = (action === 'start' && clipName.trim())
? JSON.stringify({ clipName: clipName.trim() })
: undefined;
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body })
.then(() => {
setPending(false);
// Clear the input on a successful stop so the next take starts fresh.
if (action === 'stop') setClipName('');
onRefresh();
})
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
};
const handleDelete = () => {
if (!window.confirm('Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.')) return;
window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
.then(() => onRefresh())
.catch(e => setErr(e.message || 'Delete failed'));
};
return (
<div className={'recorder-row ' + recorder.status}>
<div className="recorder-preview">
{isRec && recorder.live_asset_id
? <HlsPreview assetId={recorder.live_asset_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>
<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>
<span className="badge outline">{recorder.source}</span>
</div>
<div className="recorder-sub mono">{recorder.url}</div>
<div className="recorder-sub">
<span>{recorder.codec}</span><span>·</span>
<span>{recorder.res}</span>
</div>
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
{liveStatus?.lastError && isRec && (
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{liveStatus.lastError}</div>
)}
</div>
<div className="recorder-stats">
<div className="recorder-stat">
<div className="stat-label">Elapsed</div>
<div className="stat-val mono">{displayElapsed}</div>
</div>
<div className="recorder-stat">
<div className="stat-label">Signal</div>
<div className="stat-val signal-val" style={{ fontSize: 11, color: signalColor }}>
<span className={'signal-dot ' + displaySignal} style={{ background: signalColor }} />
{displaySignal}
</div>
</div>
{liveStatus?.currentFps != null && (
<div className="recorder-stat">
<div className="stat-label">FPS</div>
<div className="stat-val mono">{Number(liveStatus.currentFps).toFixed(1)}</div>
</div>
)}
</div>
<div className="recorder-actions">
{!isRec && (
<input
className="field-input"
value={clipName}
onChange={e => setClipName(e.target.value)}
placeholder="Clip name (optional)"
disabled={pending}
maxLength={80}
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
style={{ width: 180, 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" style={{ color: 'var(--text-3)' }}>
<Icon name="x" />
</button>
</div>
</div>
);
}
function badgeForStatus(s) {
return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
}
/* ===== Capture ===== */
function Capture({ navigate }) {
const [devices, setDevices] = React.useState([]);
const [activeIdx, setActiveIdx] = React.useState(0);
const loadDevices = () => {
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
.then(devs => setDevices(Array.isArray(devs) ? devs : []))
.catch(() => setDevices([]));
};
React.useEffect(() => { loadDevices(); }, []);
if (devices.length === 0) {
return (
<div className="page">
<div className="page-header">
<h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest</span>
<div className="spacer" />
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
No DeckLink devices found in cluster.
</div>
</div>
</div>
);
}
const active = devices[activeIdx] || devices[0];
return (
<div className="page">
<div className="page-header">
<h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest {devices.length} device{devices.length > 1 ? 's' : ''} in cluster</span>
<div className="spacer" />
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
{devices.map((d, i) => (
<button key={i} className={'btn ' + (activeIdx === i ? 'primary' : 'ghost') + ' sm'} onClick={() => setActiveIdx(i)}>
{d.model || d.device || 'DeckLink'} {d.hostname}
</button>
))}
</div>
<div className="panel" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<StatusDot status={active.online ? 'online' : 'offline'} />
<div>
<div style={{ fontWeight: 600 }}>{active.model || active.device || 'DeckLink'}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{active.hostname} · {active.ip_address}</div>
</div>
</div>
<div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Connect a source and click Refresh to see port status.</div>
</div>
</div>
</div>
);
}
/* ===== Monitors ===== */
function Monitors({ navigate }) {
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
const [grid, setGrid] = React.useState(4);
React.useEffect(() => {
const refresh = () => {
window.ZAMPP_API.fetch('/recorders')
.then(raw => {
const norm = (raw || []).map(_normRecorder);
window.ZAMPP_DATA.RECORDERS = norm;
setRecorders(norm);
})
.catch(() => {});
};
refresh();
const id = setInterval(refresh, 5000);
return () => clearInterval(id);
}, []);
const videoFeeds = recorders.filter(r => !r.audio);
const audioFeeds = recorders.filter(r => r.audio).map(r => ({ ...r, kind: 'audio' }));
const allFeeds = [...videoFeeds.map(r => ({ ...r, kind: 'video' })), ...audioFeeds];
const feeds = allFeeds.slice(0, grid * grid);
return (
<div className="page">
<div className="page-header">
<h1>Monitors</h1>
<span className="subtitle">Multi-cam live monitoring</span>
<div className="spacer" />
<div className="tab-group">
{[2, 3, 4].map(n => (
<button key={n} className={grid === n ? 'active' : ''} onClick={() => setGrid(n)}>{n}×{n}</button>
))}
</div>
</div>
<div className="page-body">
{feeds.length === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</div>
) : (
<div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
</div>
)}
</div>
</div>
);
}
function MonitorTile({ feed, seed }) {
const [levels, setLevels] = React.useState([0.65, 0.78]);
const isLive = feed.status === 'recording';
React.useEffect(() => {
if (!isLive) return;
const id = setInterval(() => {
setLevels([0.3 + Math.random() * 0.55, 0.3 + Math.random() * 0.55]);
}, 180);
return () => clearInterval(id);
}, [isLive]);
if (feed.kind === 'audio') {
return (
<div className="monitor-tile audio">
<div style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24 }}>
<Waveform seed={seed * 7} color="var(--accent)" />
</div>
<div style={{ display: 'flex', gap: 4, padding: '0 16px 16px', justifyContent: 'center' }}>
<AudioMeter level={levels[0]} vertical />
<AudioMeter level={levels[1]} vertical />
</div>
<div className="monitor-tile-label">
<span className="badge live">LIVE</span>
<span className="name">{feed.name}</span>
</div>
</div>
);
}
return (
<div className="monitor-tile">
{isLive && feed.live_asset_id
? <HlsPreview assetId={feed.live_asset_id} />
: <FauxFrame />}
{isLive && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
{isLive && <span className="badge live">REC</span>}
{feed.status === 'stopped' && <span className="badge neutral">IDLE</span>}
{feed.status === 'idle' && <span className="badge neutral">IDLE</span>}
{feed.status === 'error' && <span className="badge danger">ERR</span>}
</div>
{isLive && (
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}>
<AudioMeter level={levels[0]} vertical />
<AudioMeter level={levels[1]} vertical />
</div>
)}
<div className="monitor-tile-label">
<span className="name">{feed.name}</span>
{feed.elapsed && feed.elapsed !== '—' && <span className="time mono">{feed.elapsed}</span>}
</div>
</div>
);
}
/* ===== Schedule ===== */
const _STATUS_BADGE = {
pending: { cls: 'neutral', label: 'pending' },
running: { cls: 'success', label: 'recording' },
completed: { cls: 'accent', label: 'completed' },
cancelled: { cls: 'warning', label: 'cancelled' },
failed: { cls: 'danger', label: 'failed' },
};
function _fmtWhen(iso) {
if (!iso) return '—';
const d = new Date(iso);
// Local-time, short, human; e.g. "May 22 · 7:30 PM"
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
});
}
function _durationMin(startISO, endISO) {
return Math.round((new Date(endISO) - new Date(startISO)) / 60000);
}
function Schedule({ navigate }) {
const [schedules, setSchedules] = React.useState(null);
const [recorders, setRecorders] = React.useState([]);
const [showNew, setShowNew] = React.useState(false);
const [editing, setEditing] = React.useState(null);
const [filter, setFilter] = React.useState('upcoming');
const load = React.useCallback(() => {
window.ZAMPP_API.fetch('/schedules?status=' + filter)
.then(d => setSchedules(d.schedules || []))
.catch(() => setSchedules([]));
}, [filter]);
React.useEffect(() => { load(); }, [load]);
React.useEffect(() => { window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([])); }, []);
// Auto-refresh every 10s so the list reflects the tick loop's transitions
React.useEffect(() => {
const t = setInterval(load, 10_000);
return () => clearInterval(t);
}, [load]);
const cancel = (s) => {
if (!confirm(`Cancel scheduled recording "${s.name}"?`)) return;
window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' })
.then(load)
.catch(e => alert('Cancel failed: ' + e.message));
};
const remove = (s) => {
if (!confirm(`Delete schedule "${s.name}"?`)) return;
window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' })
.then(load)
.catch(e => alert('Delete failed: ' + e.message));
};
return (
<div className="page">
<div className="page-header">
<h1>Schedule</h1>
<span className="subtitle">Plan recorder windows · {schedules?.length || 0} {filter}</span>
<div className="spacer" />
<div className="tab-group" style={{ marginRight: 8 }}>
<button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button>
<button className={filter === 'past' ? 'active' : ''} onClick={() => setFilter('past')}>Past</button>
<button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button>
</div>
<button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
<button className="btn primary" onClick={() => setShowNew(true)} disabled={recorders.length === 0}>
<Icon name="plus" />New schedule
</button>
</div>
<div className="page-body">
{schedules === null && (
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>Loading</div>
)}
{schedules !== null && schedules.length === 0 && (
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>📅</div>
<div style={{ fontWeight: 500, fontSize: 14 }}>No {filter} recordings</div>
{filter === 'upcoming' && recorders.length === 0 && (
<div style={{ fontSize: 12, marginTop: 6 }}>Create a recorder first, then schedule it here.</div>
)}
{filter === 'upcoming' && recorders.length > 0 && (
<div style={{ fontSize: 12, marginTop: 6 }}>Click <em>New schedule</em> to plan a future recording.</div>
)}
</div>
)}
{schedules !== null && schedules.length > 0 && (
<div className="panel">
<div className="schedule-row head">
<div>Name</div>
<div>Recorder</div>
<div>Starts</div>
<div>Duration</div>
<div>Recurrence</div>
<div>Status</div>
<div></div>
</div>
{schedules.map(s => {
const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
return (
<div key={s.id} className="schedule-row">
<div style={{ fontWeight: 500, fontSize: 13 }}>
{s.name}
{s.error_message && (
<div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 3 }}>{s.error_message}</div>
)}
</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-2)' }}>{s.recorder_name || s.recorder_id.slice(0, 8)}</div>
<div className="mono" style={{ fontSize: 11.5 }}>{_fmtWhen(s.start_at)}</div>
<div className="mono" style={{ fontSize: 11.5 }}>{_durationMin(s.start_at, s.end_at)} min</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{s.recurrence === 'none' ? 'one-shot' : s.recurrence}</div>
<div><span className={`badge ${badge.cls}`}>{badge.label}</span></div>
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
{s.status === 'pending' && (
<button className="btn ghost sm" onClick={() => setEditing(s)} title="Edit name / time / recurrence">Edit</button>
)}
{(s.status === 'pending' || s.status === 'running') && (
<button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>
)}
{(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') && (
<button className="btn ghost sm" onClick={() => remove(s)} title="Delete schedule row">Delete</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{showNew && <NewScheduleModal recorders={recorders} onClose={() => setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />}
{editing && <EditScheduleModal schedule={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load(); }} />}
</div>
);
}
function EditScheduleModal({ schedule, onClose, onSaved }) {
const toLocalInput = (iso) => {
const d = new Date(iso);
const tz = d.getTimezoneOffset() * 60_000;
return new Date(d.getTime() - tz).toISOString().slice(0, 16);
};
const [form, setForm] = React.useState({
name: schedule.name,
start_at: toLocalInput(schedule.start_at),
end_at: toLocalInput(schedule.end_at),
recurrence: schedule.recurrence || 'none',
});
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
const submit = () => {
setErr(null);
if (!form.name.trim()) return setErr('Name is required');
const startD = new Date(form.start_at);
const endD = new Date(form.end_at);
if (endD <= startD) return setErr('End must be after start');
setSaving(true);
window.ZAMPP_API.fetch('/schedules/' + schedule.id, {
method: 'PUT',
body: JSON.stringify({
name: form.name.trim(),
start_at: startD.toISOString(),
end_at: endD.toISOString(),
recurrence: form.recurrence,
}),
})
.then(onSaved)
.catch(e => { setSaving(false); setErr(e.message || 'Save failed'); });
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Edit scheduled recording</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Name</label>
<input className="field-input" autoFocus value={form.name}
onChange={e => set('name', e.target.value)} />
</div>
<div className="field">
<label className="field-label">Recorder</label>
<input className="field-input mono" value={schedule.recorder_name || schedule.recorder_id} readOnly
style={{ color: 'var(--text-3)' }} />
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>Recorder can't be reassigned — delete + recreate to change.</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label className="field-label">Start</label>
<input className="field-input mono" type="datetime-local"
value={form.start_at} onChange={e => set('start_at', e.target.value)} />
</div>
<div className="field">
<label className="field-label">End</label>
<input className="field-input mono" type="datetime-local"
value={form.end_at} onChange={e => set('end_at', e.target.value)} />
</div>
</div>
<div className="field">
<label className="field-label">Recurrence</label>
<select className="field-input" value={form.recurrence}
onChange={e => set('recurrence', e.target.value)}
style={{ appearance: 'auto' }}>
<option value="none">One-shot (no repeat)</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
{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}>{saving ? 'Saving' : 'Save changes'}</button>
</div>
</div>
</div>
);
}
function NewScheduleModal({ recorders, onClose, onCreated }) {
// Default: start in 5 minutes, run for 30 min — gives the operator something
// sensible the moment the modal opens.
const now = new Date();
now.setSeconds(0, 0);
const startDefault = new Date(now.getTime() + 5 * 60 * 1000);
const endDefault = new Date(now.getTime() + 35 * 60 * 1000);
const toLocalInput = (d) => {
const tz = d.getTimezoneOffset() * 60_000;
return new Date(d.getTime() - tz).toISOString().slice(0, 16);
};
const [form, setForm] = React.useState({
name: '',
recorder_id: recorders[0]?.id || '',
start_at: toLocalInput(startDefault),
end_at: toLocalInput(endDefault),
recurrence: 'none',
});
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
const submit = () => {
setErr(null);
if (!form.name.trim()) return setErr('Name is required');
if (!form.recorder_id) return setErr('Pick a recorder');
if (!form.start_at) return setErr('Start time is required');
if (!form.end_at) return setErr('End time is required');
const startD = new Date(form.start_at);
const endD = new Date(form.end_at);
if (endD <= startD) return setErr('End must be after start');
// Warn (but allow) start times in the past — the scheduler tick will fire
// them immediately, which is occasionally what the operator wants
// (e.g. "record the next 30 minutes starting now").
if (startD < new Date(Date.now() - 60_000)) {
if (!confirm('Start time is in the past recorder will fire immediately when saved.\nContinue?')) return;
}
// Datetime-local inputs are in the browser's local zone; ship as ISO so
// Postgres stores them as TIMESTAMPTZ properly.
const body = {
name: form.name.trim(),
recorder_id: form.recorder_id,
start_at: startD.toISOString(),
end_at: endD.toISOString(),
recurrence: form.recurrence,
};
setSaving(true);
window.ZAMPP_API.fetch('/schedules', { method: 'POST', body: JSON.stringify(body) })
.then(() => onCreated())
.catch(e => { setSaving(false); setErr(e.message || 'Failed to schedule'); });
};
const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) submit(); };
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>New scheduled recording</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Name</label>
<input className="field-input" autoFocus value={form.name}
onChange={e => set('name', e.target.value)}
onKeyDown={onKey} placeholder="Morning service stream" />
</div>
<div className="field">
<label className="field-label">Recorder</label>
<select className="field-input" value={form.recorder_id}
onChange={e => set('recorder_id', e.target.value)}
style={{ appearance: 'auto' }}>
{recorders.length === 0 && <option value=""> No recorders defined </option>}
{recorders.map(r => (
<option key={r.id} value={r.id}>
{r.name} · {r.source_type?.toUpperCase() || '?'}
</option>
))}
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label className="field-label">Start</label>
<input className="field-input mono" type="datetime-local"
value={form.start_at}
onChange={e => set('start_at', e.target.value)} />
</div>
<div className="field">
<label className="field-label">End</label>
<input className="field-input mono" type="datetime-local"
value={form.end_at}
onChange={e => set('end_at', e.target.value)} />
</div>
</div>
<div className="field">
<label className="field-label">Recurrence</label>
<select className="field-input" value={form.recurrence}
onChange={e => set('recurrence', e.target.value)}
style={{ appearance: 'auto' }}>
<option value="none">One-shot (no repeat)</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
Recurring schedules queue the next occurrence as soon as the current one completes.
</div>
</div>
{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}>
{saving ? 'Scheduling…' : 'Schedule recording'}
</button>
</div>
</div>
</div>
);
}
Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule });