2026-05-22 10:07:13 -04:00
|
|
|
|
// screens-ingest.jsx — Upload, Recorders, Capture, Monitors
|
2026-05-22 08:20:15 -04:00
|
|
|
|
|
2026-05-22 11:10:00 -04:00
|
|
|
|
/* ===== 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);
|
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-26 22:06:14 -04:00
|
|
|
|
return _xhrPost((window.ZAMPP_API_PREFIX || '/api/v1') + '/upload/simple', fd,
|
2026-05-22 11:10:00 -04:00
|
|
|
|
(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));
|
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-26 22:06:14 -04:00
|
|
|
|
const partRes = await _xhrPost((window.ZAMPP_API_PREFIX || '/api/v1') + '/upload/part', fd,
|
2026-05-22 11:10:00 -04:00
|
|
|
|
(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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 10:07:13 -04:00
|
|
|
|
/* ===== Upload ===== */
|
2026-05-22 08:20:15 -04:00
|
|
|
|
function Upload({ navigate }) {
|
feat(comments): persistent frame-anchored comments on asset detail
- migration 010: asset_comments table (id, asset_id, user_id, body,
frame_ms, resolved, timestamps) with index on asset_id+created_at
- new routes mounted at /api/v1/assets/:assetId/comments — GET/POST/
PATCH/DELETE with author join (display_name + initials), nullable
user_id so comments still attach when AUTH_ENABLED is off
- Asset detail loads comments from the API on mount instead of the
empty ZAMPP_DATA.COMMENTS seed; addComment POSTs and merges the
returned row; resolved-toggle and delete are wired
- CommentsList: new trash-icon delete action per comment, helpful
empty-state copy ('Add one below to mark a frame'), tooltips on
the timestamp and resolved buttons
Now editor comments survive page reload, are visible to other users
via the same API, and pin reliably to frame_ms (integer) instead of
a parsed HH:MM:SS:FF string.
2026-05-23 00:21:11 -04:00
|
|
|
|
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
|
2026-05-22 10:07:13 -04:00
|
|
|
|
const [files, setFiles] = React.useState([]);
|
2026-05-22 11:10:00 -04:00
|
|
|
|
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
|
2026-05-22 08:20:15 -04:00
|
|
|
|
|
2026-05-22 11:10:00 -04:00
|
|
|
|
const updateFile = React.useCallback((id, patch) => {
|
|
|
|
|
|
setFiles(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
|
2026-05-22 08:20:15 -04:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-05-22 11:10:00 -04:00
|
|
|
|
const startUpload = React.useCallback((entry, pid) => {
|
|
|
|
|
|
_uploadFile(entry.file, pid, (pct) => updateFile(entry.id, { progress: pct }))
|
2026-05-24 20:36:04 -04:00
|
|
|
|
.then(() => {
|
|
|
|
|
|
updateFile(entry.id, { status: 'done', progress: 100 });
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('df:assets-changed'));
|
|
|
|
|
|
})
|
2026-05-22 11:10:00 -04:00
|
|
|
|
.catch(e => updateFile(entry.id, { status: 'error', progress: 0, error: e.message }));
|
|
|
|
|
|
}, [updateFile]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleDrop = React.useCallback((e) => {
|
2026-05-22 10:07:13 -04:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const dropped = Array.from(e.dataTransfer ? e.dataTransfer.files : e.target.files);
|
2026-05-22 11:10:00 -04:00
|
|
|
|
const pid = projectId || PROJECTS[0]?.id || '';
|
|
|
|
|
|
const newEntries = dropped.map((f, i) => ({
|
|
|
|
|
|
id: Date.now() + i,
|
|
|
|
|
|
name: f.name,
|
2026-05-22 10:07:13 -04:00
|
|
|
|
size: window.ZAMPP_API.fmtSize(f.size),
|
2026-05-22 11:10:00 -04:00
|
|
|
|
file: f,
|
|
|
|
|
|
progress: 0,
|
|
|
|
|
|
status: 'uploading',
|
|
|
|
|
|
error: null,
|
2026-05-22 10:07:13 -04:00
|
|
|
|
}));
|
2026-05-22 11:10:00 -04:00
|
|
|
|
setFiles(prev => [...prev, ...newEntries]);
|
|
|
|
|
|
newEntries.forEach(entry => startUpload(entry, pid));
|
|
|
|
|
|
}, [projectId, startUpload]);
|
2026-05-22 10:07:13 -04:00
|
|
|
|
|
2026-05-22 08:20:15 -04:00
|
|
|
|
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">
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
<div>
|
|
|
|
|
|
<label className="field-label">Project</label>
|
2026-05-22 11:10:00 -04:00
|
|
|
|
<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>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-22 11:10:00 -04:00
|
|
|
|
<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();
|
|
|
|
|
|
}}>
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<Icon name="upload" size={32} style={{ color: 'var(--text-3)' }} />
|
2026-05-22 08:20:15 -04:00
|
|
|
|
<div style={{ fontSize: 15, fontWeight: 500 }}>Drop files here or click to browse</div>
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<div className="muted" style={{ fontSize: 12.5 }}>Video, audio, and image files</div>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
<div className="dropzone-formats">
|
2026-05-22 11:10:00 -04:00
|
|
|
|
{['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f =>
|
|
|
|
|
|
<span key={f} className="badge outline">{f}</span>)}
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-22 10:07:13 -04:00
|
|
|
|
{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 }} />
|
2026-05-22 11:10:00 -04:00
|
|
|
|
<button className="btn ghost sm" onClick={() => setFiles(f => f.filter(x => x.status === 'uploading'))}>Clear done</button>
|
2026-05-22 10:07:13 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="panel">
|
|
|
|
|
|
{files.map(f => (
|
|
|
|
|
|
<div key={f.id} className="upload-row">
|
2026-05-22 11:10:00 -04:00
|
|
|
|
<Icon name={f.name.match(/\.(wav|aif|mp3|aiff)$/i) ? 'audio' : 'video'} size={16}
|
|
|
|
|
|
style={{ color: 'var(--text-3)' }} />
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<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' }}>
|
2026-05-22 11:10:00 -04:00
|
|
|
|
<div style={{
|
|
|
|
|
|
width: f.progress + '%', height: '100%',
|
|
|
|
|
|
background: f.status === 'done' ? 'var(--success)' : f.status === 'error' ? 'var(--danger)' : 'var(--accent)',
|
|
|
|
|
|
transition: 'width 200ms',
|
|
|
|
|
|
}} />
|
2026-05-22 10:07:13 -04:00
|
|
|
|
</div>
|
2026-05-22 11:10:00 -04:00
|
|
|
|
{f.status === 'error' && (
|
|
|
|
|
|
<div style={{ marginTop: 3, fontSize: 11, color: 'var(--danger)' }}>{f.error}</div>
|
|
|
|
|
|
)}
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
2026-05-22 11:10:00 -04:00
|
|
|
|
<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 + '%'}
|
2026-05-22 10:07:13 -04:00
|
|
|
|
</span>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
2026-05-22 10:07:13 -04:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
2026-05-22 10:07:13 -04:00
|
|
|
|
)}
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 16:05:41 -04:00
|
|
|
|
/* ===== YouTube importer ===== */
|
|
|
|
|
|
// Accept the same three URL shapes the API validates against.
|
|
|
|
|
|
const _YT_PATTERNS = [
|
|
|
|
|
|
/^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?[^ ]*v=[A-Za-z0-9_-]{11}/i,
|
|
|
|
|
|
/^https?:\/\/youtu\.be\/[A-Za-z0-9_-]{11}/i,
|
|
|
|
|
|
/^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[A-Za-z0-9_-]{11}/i,
|
|
|
|
|
|
];
|
|
|
|
|
|
function _looksLikeYouTube(s) {
|
|
|
|
|
|
return typeof s === 'string' && _YT_PATTERNS.some(re => re.test(s.trim()));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function YouTubeImport({ navigate }) {
|
|
|
|
|
|
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
|
|
|
|
|
|
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
|
|
|
|
|
|
const [url, setUrl] = React.useState('');
|
|
|
|
|
|
const [queue, setQueue] = React.useState([]); // { id, url, status, progress, title, error, assetId, jobId }
|
|
|
|
|
|
const [submitting, setSubmitting] = React.useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
const valid = _looksLikeYouTube(url);
|
|
|
|
|
|
|
|
|
|
|
|
const updateRow = React.useCallback((id, patch) => {
|
|
|
|
|
|
setQueue(prev => prev.map(r => r.id === id ? { ...r, ...patch } : r));
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// Poll the asset row to pick up the title once yt-dlp resolves it, and the
|
|
|
|
|
|
// proxy job's progress so the queue row reflects the full lifecycle, not
|
|
|
|
|
|
// just the import step.
|
|
|
|
|
|
const pollRow = React.useCallback((row) => {
|
|
|
|
|
|
if (!row.assetId) return;
|
|
|
|
|
|
let stopped = false;
|
|
|
|
|
|
const tick = async () => {
|
|
|
|
|
|
if (stopped) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const asset = await window.ZAMPP_API.fetch('/assets/' + row.assetId);
|
|
|
|
|
|
const patch = {};
|
|
|
|
|
|
if (asset.display_name && asset.display_name !== row.url) patch.title = asset.display_name;
|
|
|
|
|
|
if (asset.status === 'ready') {
|
|
|
|
|
|
patch.status = 'done';
|
|
|
|
|
|
patch.progress = 100;
|
2026-05-24 20:36:04 -04:00
|
|
|
|
window.dispatchEvent(new CustomEvent('df:assets-changed'));
|
2026-05-23 16:05:41 -04:00
|
|
|
|
} else if (asset.status === 'error') {
|
|
|
|
|
|
patch.status = 'error';
|
|
|
|
|
|
patch.error = patch.error || 'Import failed — check the Jobs screen for details.';
|
|
|
|
|
|
} else if (asset.status === 'processing') {
|
|
|
|
|
|
patch.status = 'processing';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Object.keys(patch).length) updateRow(row.id, patch);
|
|
|
|
|
|
if (asset.status === 'ready' || asset.status === 'error') return;
|
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
|
setTimeout(tick, 3000);
|
|
|
|
|
|
};
|
|
|
|
|
|
tick();
|
|
|
|
|
|
return () => { stopped = true; };
|
|
|
|
|
|
}, [updateRow]);
|
|
|
|
|
|
|
|
|
|
|
|
const submit = React.useCallback(async () => {
|
|
|
|
|
|
if (!valid || !projectId || submitting) return;
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
const rowId = Date.now();
|
|
|
|
|
|
const row = {
|
|
|
|
|
|
id: rowId,
|
|
|
|
|
|
url: url.trim(),
|
|
|
|
|
|
status: 'queued',
|
|
|
|
|
|
progress: 0,
|
|
|
|
|
|
title: '',
|
|
|
|
|
|
error: null,
|
|
|
|
|
|
assetId: null,
|
|
|
|
|
|
jobId: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
setQueue(prev => [row, ...prev]);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await window.ZAMPP_API.fetch('/imports/youtube', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify({ url: row.url, projectId }),
|
|
|
|
|
|
});
|
|
|
|
|
|
updateRow(rowId, { assetId: res.assetId, jobId: res.jobId, status: 'downloading' });
|
|
|
|
|
|
pollRow({ ...row, assetId: res.assetId, jobId: res.jobId });
|
|
|
|
|
|
setUrl('');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
updateRow(rowId, { status: 'error', error: e.message || 'Failed to start import' });
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [valid, projectId, submitting, url, updateRow, pollRow]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="page">
|
|
|
|
|
|
<div className="page-header">
|
|
|
|
|
|
<h1>YouTube</h1>
|
|
|
|
|
|
<span className="subtitle">Paste a link — we download and import the best available MP4.</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="page-body">
|
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
|
|
|
|
|
<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="field" style={{ marginBottom: 8 }}>
|
|
|
|
|
|
<label className="field-label">YouTube URL</label>
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="field-input mono"
|
|
|
|
|
|
value={url}
|
|
|
|
|
|
onChange={e => setUrl(e.target.value)}
|
|
|
|
|
|
onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }}
|
|
|
|
|
|
placeholder="https://www.youtube.com/watch?v=… or https://youtu.be/…"
|
|
|
|
|
|
style={{ flex: 1 }}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="btn primary"
|
|
|
|
|
|
onClick={submit}
|
|
|
|
|
|
disabled={!valid || !projectId || submitting}
|
|
|
|
|
|
title={!valid ? 'Paste a YouTube URL (youtube.com/watch, youtu.be, or shorts)' : ''}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="download" />Import
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{url && !valid && (
|
|
|
|
|
|
<div style={{ fontSize: 11.5, color: 'var(--danger)', marginTop: 4 }}>
|
|
|
|
|
|
That doesn't look like a YouTube URL.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 6 }}>
|
|
|
|
|
|
Only import videos you have rights to use. Private, age-gated, and members-only videos are not supported.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{queue.length > 0 && (
|
|
|
|
|
|
<div style={{ marginTop: 20 }}>
|
|
|
|
|
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
|
Queue <span className="badge neutral">{queue.length}</span>
|
|
|
|
|
|
<span style={{ flex: 1 }} />
|
|
|
|
|
|
<button className="btn ghost sm" onClick={() => setQueue(q => q.filter(r => r.status !== 'done' && r.status !== 'error'))}>
|
|
|
|
|
|
Clear finished
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="panel">
|
|
|
|
|
|
{queue.map(r => {
|
|
|
|
|
|
const statusColor =
|
|
|
|
|
|
r.status === 'done' ? 'var(--success)' :
|
|
|
|
|
|
r.status === 'error' ? 'var(--danger)' : 'var(--text-3)';
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={r.id} className="upload-row">
|
|
|
|
|
|
<Icon name="link" 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, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
|
|
|
|
{r.title || r.url}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{r.title && (
|
|
|
|
|
|
<div className="muted mono" style={{ fontSize: 10.5, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={r.url}>
|
|
|
|
|
|
{r.url}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div style={{ marginTop: 6, height: 4, background: 'var(--bg-3)', borderRadius: 99, overflow: 'hidden' }}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
width: (r.status === 'done' ? 100 : r.progress) + '%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
background: r.status === 'done' ? 'var(--success)' : r.status === 'error' ? 'var(--danger)' : 'var(--accent)',
|
|
|
|
|
|
transition: 'width 200ms',
|
|
|
|
|
|
}} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{r.error && (
|
|
|
|
|
|
<div style={{ marginTop: 3, fontSize: 11, color: 'var(--danger)' }}>{r.error}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="mono" style={{ fontSize: 11.5, minWidth: 88, textAlign: 'right', color: statusColor }}>
|
|
|
|
|
|
{r.status === 'done' ? '✓ done'
|
|
|
|
|
|
: r.status === 'error' ? '✗ failed'
|
|
|
|
|
|
: r.status === 'processing'? 'processing'
|
|
|
|
|
|
: r.status === 'downloading' ? 'downloading'
|
|
|
|
|
|
: 'queued'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
|
|
|
|
/* ===== 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;
|
2026-05-24 16:52:04 -04:00
|
|
|
|
let destroyed = false;
|
|
|
|
|
|
let retryTimer = 0;
|
|
|
|
|
|
let retryCount = 0;
|
|
|
|
|
|
const MAX_RETRIES = 8;
|
|
|
|
|
|
|
|
|
|
|
|
const clearRetry = () => { if (retryTimer) { clearTimeout(retryTimer); retryTimer = 0; } };
|
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
|
|
|
|
|
|
|
|
|
|
// Safari can play HLS natively; everything else needs hls.js.
|
|
|
|
|
|
if (v.canPlayType('application/vnd.apple.mpegurl')) {
|
2026-05-24 16:52:04 -04:00
|
|
|
|
const tryLoad = () => {
|
|
|
|
|
|
if (destroyed) return;
|
|
|
|
|
|
v.removeAttribute('src');
|
|
|
|
|
|
v.load();
|
|
|
|
|
|
v.src = url;
|
|
|
|
|
|
v.play().catch(() => {});
|
|
|
|
|
|
};
|
|
|
|
|
|
const onErr = () => {
|
|
|
|
|
|
if (destroyed || retryCount >= MAX_RETRIES) { setErr('playback failed'); return; }
|
|
|
|
|
|
retryCount++;
|
|
|
|
|
|
clearRetry();
|
|
|
|
|
|
retryTimer = setTimeout(tryLoad, Math.min(500 * Math.pow(2, retryCount - 1), 8000));
|
|
|
|
|
|
setErr('connecting…');
|
|
|
|
|
|
};
|
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
|
|
|
|
v.addEventListener('error', onErr);
|
2026-05-24 16:52:04 -04:00
|
|
|
|
v.addEventListener('playing', () => { retryCount = 0; setErr(null); }, { once: false });
|
|
|
|
|
|
tryLoad();
|
|
|
|
|
|
return () => { destroyed = true; clearRetry(); v.removeEventListener('error', onErr); };
|
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
|
|
|
|
}
|
|
|
|
|
|
if (!window.Hls) { setErr('hls.js missing'); return; }
|
|
|
|
|
|
|
2026-05-24 16:52:04 -04:00
|
|
|
|
let hls = null;
|
|
|
|
|
|
|
|
|
|
|
|
const startHls = () => {
|
|
|
|
|
|
if (destroyed) return;
|
|
|
|
|
|
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) {
|
|
|
|
|
|
if (retryCount >= MAX_RETRIES) { setErr(data.details || 'hls error'); return; }
|
|
|
|
|
|
retryCount++;
|
|
|
|
|
|
clearRetry();
|
|
|
|
|
|
try { hls.destroy(); } catch (_) {}
|
|
|
|
|
|
hls = null;
|
|
|
|
|
|
setErr('connecting…');
|
|
|
|
|
|
retryTimer = setTimeout(startHls, Math.min(500 * Math.pow(2, retryCount - 1), 8000));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
hls.on(window.Hls.Events.MANIFEST_PARSED, () => { retryCount = 0; setErr(null); });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
startHls();
|
|
|
|
|
|
v.play().catch(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
return () => { destroyed = true; clearRetry(); if (hls) { try { hls.destroy(); } catch (_) {} } };
|
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
|
|
|
|
}, [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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 10:07:13 -04:00
|
|
|
|
/* ===== Recorders ===== */
|
2026-05-22 10:55:19 -04:00
|
|
|
|
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') + ':' +
|
2026-05-22 12:24:10 -04:00
|
|
|
|
String(Math.floor((s % 3600) / 60)).padStart(2, '00') + ':' +
|
2026-05-22 10:55:19 -04: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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 08:20:15 -04:00
|
|
|
|
function Recorders({ navigate, onNew }) {
|
2026-05-23 00:17:36 -04:00
|
|
|
|
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
|
2026-05-22 10:07:13 -04:00
|
|
|
|
|
2026-05-22 10:55:19 -04:00
|
|
|
|
const refresh = React.useCallback(() => {
|
|
|
|
|
|
window.ZAMPP_API.fetch('/recorders')
|
|
|
|
|
|
.then(raw => {
|
|
|
|
|
|
const norm = (raw || []).map(_normRecorder);
|
|
|
|
|
|
window.ZAMPP_DATA.RECORDERS = norm;
|
|
|
|
|
|
setRecorders(norm);
|
|
|
|
|
|
})
|
2026-05-26 10:10:44 -04:00
|
|
|
|
.catch(err => {
|
|
|
|
|
|
// apiFetch already redirects on 401 — don't log noise, interval
|
|
|
|
|
|
// will be cleared automatically when the component unmounts on redirect (#55)
|
|
|
|
|
|
if (err && err.message && err.message.includes('Unauthenticated')) return;
|
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-26 22:06:14 -04:00
|
|
|
|
window.DF_LOG.warn('[recorders] poll error:', err?.message);
|
2026-05-26 10:10:44 -04:00
|
|
|
|
});
|
2026-05-22 10:55:19 -04:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-05-22 10:07:13 -04:00
|
|
|
|
React.useEffect(() => {
|
2026-05-22 11:35:13 -04:00
|
|
|
|
refresh();
|
2026-05-22 10:55:19 -04:00
|
|
|
|
const id = setInterval(refresh, 10000);
|
2026-05-23 14:52:04 -04:00
|
|
|
|
// Any screen that creates/starts/stops/deletes a recorder dispatches
|
|
|
|
|
|
// df:recorders-changed; refresh immediately instead of waiting for the tick.
|
|
|
|
|
|
const onChange = () => refresh();
|
|
|
|
|
|
window.addEventListener('df:recorders-changed', onChange);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
clearInterval(id);
|
|
|
|
|
|
window.removeEventListener('df:recorders-changed', onChange);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [refresh]);
|
2026-05-22 10:07:13 -04:00
|
|
|
|
|
|
|
|
|
|
const liveCount = recorders.filter(r => r.status === 'recording').length;
|
|
|
|
|
|
const errCount = recorders.filter(r => r.status === 'error').length;
|
|
|
|
|
|
|
2026-05-22 08:20:15 -04:00
|
|
|
|
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" />
|
2026-05-22 10:07:13 -04:00
|
|
|
|
{(liveCount > 0 || errCount > 0) && (
|
|
|
|
|
|
<div className="status-pip">
|
|
|
|
|
|
<span className="dot" />
|
|
|
|
|
|
<span>{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-22 10:55:19 -04:00
|
|
|
|
<button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
<button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="page-body">
|
2026-05-22 10:07:13 -04:00
|
|
|
|
{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">
|
2026-05-22 10:55:19 -04:00
|
|
|
|
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
|
2026-05-22 10:07:13 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 10:55:19 -04:00
|
|
|
|
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
|
|
|
|
|
const [recorder, setRecorder] = React.useState(initialRecorder);
|
|
|
|
|
|
const [pending, setPending] = React.useState(false);
|
|
|
|
|
|
const [err, setErr] = React.useState(null);
|
2026-05-22 11:35:13 -04:00
|
|
|
|
const [liveStatus, setLiveStatus] = React.useState(null);
|
2026-05-22 23:41:03 -04:00
|
|
|
|
const [clipName, setClipName] = React.useState('');
|
2026-05-22 10:07:13 -04:00
|
|
|
|
const isRec = recorder.status === 'recording';
|
|
|
|
|
|
|
2026-05-22 10:55:19 -04:00
|
|
|
|
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
|
|
|
|
|
|
|
2026-05-22 11:35:13 -04:00
|
|
|
|
// 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)';
|
|
|
|
|
|
|
2026-05-22 10:07:13 -04:00
|
|
|
|
const toggle = () => {
|
2026-05-22 10:55:19 -04:00
|
|
|
|
if (pending) return;
|
2026-05-22 10:07:13 -04:00
|
|
|
|
const action = isRec ? 'stop' : 'start';
|
2026-05-22 10:55:19 -04:00
|
|
|
|
setPending(true);
|
|
|
|
|
|
setErr(null);
|
|
|
|
|
|
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
|
2026-05-22 23:41:03 -04:00
|
|
|
|
// 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();
|
2026-05-23 14:52:04 -04:00
|
|
|
|
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
|
|
|
|
|
|
// Stopping a recorder flips its asset from 'live' to 'ready' on the
|
|
|
|
|
|
// server side; tell the library/dashboard to re-pull.
|
|
|
|
|
|
if (action === 'stop') {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('df:assets-changed'));
|
|
|
|
|
|
}
|
2026-05-22 23:41:03 -04:00
|
|
|
|
})
|
2026-05-22 10:55:19 -04:00
|
|
|
|
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
|
2026-05-22 10:07:13 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-22 12:24:10 -04:00
|
|
|
|
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' })
|
2026-05-23 14:52:04 -04:00
|
|
|
|
.then(() => {
|
|
|
|
|
|
onRefresh();
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('df:assets-changed'));
|
|
|
|
|
|
})
|
2026-05-22 12:24:10 -04:00
|
|
|
|
.catch(e => setErr(e.message || 'Delete failed'));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-22 08:20:15 -04:00
|
|
|
|
return (
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<div className={'recorder-row ' + recorder.status}>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
<div className="recorder-preview">
|
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
|
|
|
|
{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>}
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="recorder-info">
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
<span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.name}</span>
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<span className={'badge ' + badgeForStatus(recorder.status)}>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
<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>
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<span>{recorder.res}</span>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
2026-05-22 10:55:19 -04:00
|
|
|
|
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
|
2026-05-22 11:35:13 -04:00
|
|
|
|
{liveStatus?.lastError && isRec && (
|
|
|
|
|
|
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{liveStatus.lastError}</div>
|
|
|
|
|
|
)}
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="recorder-stats">
|
|
|
|
|
|
<div className="recorder-stat">
|
|
|
|
|
|
<div className="stat-label">Elapsed</div>
|
2026-05-22 11:35:13 -04:00
|
|
|
|
<div className="stat-val mono">{displayElapsed}</div>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="recorder-stat">
|
2026-05-22 11:35:13 -04:00
|
|
|
|
<div className="stat-label">Signal</div>
|
2026-05-23 13:19:48 -04:00
|
|
|
|
<div className="stat-val signal-val" style={{ fontSize: 11, color: signalColor }}>
|
|
|
|
|
|
<span className={'signal-dot ' + displaySignal} style={{ background: signalColor }} />
|
|
|
|
|
|
{displaySignal}
|
|
|
|
|
|
</div>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
2026-05-22 11:35:13 -04:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="recorder-actions">
|
2026-05-22 23:41:03 -04:00
|
|
|
|
{!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."
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-05-22 10:07:13 -04:00
|
|
|
|
{isRec
|
2026-05-22 10:55:19 -04:00
|
|
|
|
? <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>}
|
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-26 22:06:14 -04:00
|
|
|
|
<button className="icon-btn" onClick={handleDelete} title="Delete recorder" aria-label="Delete recorder" style={{ color: 'var(--text-3)' }}>
|
2026-05-22 12:24:10 -04:00
|
|
|
|
<Icon name="x" />
|
|
|
|
|
|
</button>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 10:07:13 -04:00
|
|
|
|
function badgeForStatus(s) {
|
|
|
|
|
|
return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ===== Capture ===== */
|
|
|
|
|
|
|
2026-05-27 09:53:09 -04:00
|
|
|
|
function _captureSignalChip(sig) {
|
|
|
|
|
|
switch (sig) {
|
|
|
|
|
|
case 'receiving': return { label: 'RECEIVING', color: 'var(--success)', pulse: true };
|
|
|
|
|
|
case 'connecting': return { label: 'CONNECTING', color: 'var(--accent)', pulse: true };
|
|
|
|
|
|
case 'lost': return { label: 'LOST', color: 'var(--danger)', pulse: false };
|
|
|
|
|
|
case 'error': return { label: 'ERROR', color: 'var(--danger)', pulse: false };
|
|
|
|
|
|
case 'idle': return { label: 'IDLE', color: 'var(--text-3)', pulse: false };
|
|
|
|
|
|
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)', pulse: false };
|
|
|
|
|
|
default: return { label: sig || '—', color: 'var(--text-4)', pulse: false };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-22 10:55:19 -04:00
|
|
|
|
|
2026-05-27 09:53:09 -04:00
|
|
|
|
function CapturePortChip({ port, sigEntry }) {
|
|
|
|
|
|
const sig = sigEntry ? sigEntry.signal : null;
|
|
|
|
|
|
const { label, color, pulse } = _captureSignalChip(sig);
|
|
|
|
|
|
const isReceiving = sig === 'receiving';
|
|
|
|
|
|
const portLabel = port.device ? port.device.split('/').pop() : `port ${port.index}`;
|
2026-05-22 10:07:13 -04:00
|
|
|
|
|
2026-05-27 09:53:09 -04:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
title={sigEntry ? `${sigEntry.recorder_name || 'recorder'} — ${label}` : label}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
|
|
|
|
padding: '6px 12px', borderRadius: 5,
|
|
|
|
|
|
background: isReceiving ? 'rgba(45,212,168,0.08)' : 'var(--bg-2)',
|
|
|
|
|
|
border: `1px solid ${isReceiving ? 'rgba(45,212,168,0.35)' : 'var(--border)'}`,
|
|
|
|
|
|
minWidth: 120,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span style={{
|
|
|
|
|
|
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
|
|
|
|
|
background: sig ? color : 'var(--text-4)',
|
|
|
|
|
|
animation: pulse ? 'signalPulse 1.4s ease-in-out infinite' : 'none',
|
|
|
|
|
|
boxShadow: isReceiving ? `0 0 6px ${color}` : 'none',
|
|
|
|
|
|
}} />
|
|
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
|
|
|
|
<div style={{ fontSize: 11.5, fontFamily: 'var(--font-mono)', color: 'var(--text-2)', fontWeight: 600 }}>
|
|
|
|
|
|
{portLabel}
|
2026-05-22 10:07:13 -04:00
|
|
|
|
</div>
|
2026-05-27 09:53:09 -04:00
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 1 }}>
|
|
|
|
|
|
<span style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.05em', color }}>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{sigEntry && sigEntry.currentFps != null && (
|
|
|
|
|
|
<span style={{ fontSize: 9.5, color: 'var(--text-4)', fontFamily: 'var(--font-mono)' }}>
|
|
|
|
|
|
{Number(sigEntry.currentFps).toFixed(1)} fps
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function CaptureNodeCard({ node, ports, portSignals }) {
|
|
|
|
|
|
const svgRef = React.useRef(null);
|
|
|
|
|
|
const nodeSignalMap = React.useMemo(() => {
|
|
|
|
|
|
const map = new Map();
|
|
|
|
|
|
ports.forEach(p => {
|
|
|
|
|
|
const entry = portSignals[`${node.node_id}:${p.index}`];
|
|
|
|
|
|
if (entry) map.set(p.index, entry.signal);
|
|
|
|
|
|
});
|
|
|
|
|
|
return map;
|
|
|
|
|
|
}, [node.node_id, ports, portSignals]);
|
|
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
if (!svgRef.current || !window.BMDCards || ports.length === 0) return;
|
|
|
|
|
|
svgRef.current.innerHTML = '';
|
|
|
|
|
|
const svg = window.BMDCards.render({
|
|
|
|
|
|
model: ports[0].model || '',
|
|
|
|
|
|
deviceCount: ports.length,
|
|
|
|
|
|
compact: true,
|
|
|
|
|
|
portSignals: nodeSignalMap,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (svg) svgRef.current.appendChild(svg);
|
|
|
|
|
|
}, [node.node_id, ports.length, nodeSignalMap]);
|
|
|
|
|
|
|
|
|
|
|
|
const receivingCount = ports.filter(p => {
|
|
|
|
|
|
const e = portSignals[`${node.node_id}:${p.index}`];
|
|
|
|
|
|
return e && e.signal === 'receiving';
|
|
|
|
|
|
}).length;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="panel" style={{ padding: 0, overflow: 'hidden' }}>
|
|
|
|
|
|
{/* Node header */}
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
display: 'flex', alignItems: 'center', gap: 10,
|
|
|
|
|
|
padding: '12px 16px',
|
|
|
|
|
|
borderBottom: '1px solid var(--border)',
|
|
|
|
|
|
background: 'var(--bg-2)',
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<StatusDot status={node.online !== false ? 'online' : 'offline'} />
|
|
|
|
|
|
<div style={{ flex: 1 }}>
|
|
|
|
|
|
<div style={{ fontWeight: 600, fontSize: 13 }}>
|
|
|
|
|
|
{ports[0].model || 'DeckLink'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text-3)', fontFamily: 'var(--font-mono)' }}>
|
|
|
|
|
|
{node.hostname}{node.ip_address ? ` · ${node.ip_address}` : ''}
|
2026-05-22 10:07:13 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-27 09:53:09 -04:00
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
|
|
|
|
{receivingCount > 0 && (
|
|
|
|
|
|
<span style={{
|
|
|
|
|
|
fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 3,
|
|
|
|
|
|
background: 'rgba(45,212,168,0.15)', color: 'var(--success)',
|
|
|
|
|
|
animation: 'signalPulse 1.4s ease-in-out infinite',
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{receivingCount} LIVE
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span style={{
|
|
|
|
|
|
fontSize: 10, fontWeight: 600, padding: '2px 7px', borderRadius: 3,
|
|
|
|
|
|
background: 'rgba(91,124,250,0.12)', color: 'var(--accent)',
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{ports.length} PORT{ports.length !== 1 ? 'S' : ''}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-05-22 10:07:13 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-27 09:53:09 -04:00
|
|
|
|
{/* Port chips */}
|
|
|
|
|
|
<div style={{ padding: '14px 16px', display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
|
|
|
|
|
{ports.map(p => (
|
|
|
|
|
|
<CapturePortChip
|
|
|
|
|
|
key={p.index}
|
|
|
|
|
|
port={p}
|
|
|
|
|
|
sigEntry={portSignals[`${node.node_id}:${p.index}`] || null}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* BMD card SVG diagram */}
|
|
|
|
|
|
{window.BMDCards && (
|
|
|
|
|
|
<div ref={svgRef} className="bmd-card-diagram" style={{ padding: '0 16px 14px' }} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Capture({ navigate }) {
|
|
|
|
|
|
const [devices, setDevices] = React.useState([]);
|
|
|
|
|
|
const [portSignals, setPortSignals] = React.useState({});
|
|
|
|
|
|
const [lastPoll, setLastPoll] = React.useState(null);
|
|
|
|
|
|
|
|
|
|
|
|
// Group devices by node
|
|
|
|
|
|
const nodeGroups = React.useMemo(() => {
|
|
|
|
|
|
const map = new Map();
|
|
|
|
|
|
devices.forEach(d => {
|
|
|
|
|
|
const key = d.node_id || d.hostname || 'unknown';
|
|
|
|
|
|
if (!map.has(key)) map.set(key, { node_id: d.node_id, hostname: d.hostname, ip_address: d.ip_address, online: d.online, ports: [] });
|
|
|
|
|
|
map.get(key).ports.push(d);
|
|
|
|
|
|
});
|
|
|
|
|
|
return Array.from(map.values());
|
|
|
|
|
|
}, [devices]);
|
|
|
|
|
|
|
|
|
|
|
|
// Load device list once (changes rarely)
|
|
|
|
|
|
const loadDevices = React.useCallback(() => {
|
|
|
|
|
|
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
|
|
|
|
|
|
.then(devs => setDevices(Array.isArray(devs) ? devs : []))
|
|
|
|
|
|
.catch(() => setDevices([]));
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// Poll signal state every 3s
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
const poll = () => {
|
|
|
|
|
|
window.ZAMPP_API.fetch('/cluster/devices/blackmagic/signal')
|
|
|
|
|
|
.then(entries => {
|
|
|
|
|
|
const map = {};
|
|
|
|
|
|
(entries || []).forEach(e => { map[`${e.node_id}:${e.index}`] = e; });
|
|
|
|
|
|
setPortSignals(map);
|
|
|
|
|
|
setLastPoll(new Date());
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
};
|
|
|
|
|
|
poll();
|
|
|
|
|
|
const id = setInterval(poll, 3000);
|
|
|
|
|
|
return () => clearInterval(id);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => { loadDevices(); }, []);
|
|
|
|
|
|
|
|
|
|
|
|
const totalPorts = devices.length;
|
|
|
|
|
|
const receivingPorts = Object.values(portSignals).filter(e => e.signal === 'receiving').length;
|
2026-05-22 08:20:15 -04:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="page">
|
|
|
|
|
|
<div className="page-header">
|
|
|
|
|
|
<h1>Capture</h1>
|
2026-05-27 09:53:09 -04:00
|
|
|
|
<span className="subtitle">DeckLink SDI ingest</span>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
<div className="spacer" />
|
2026-05-27 09:53:09 -04:00
|
|
|
|
{totalPorts > 0 && (
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginRight: 8 }}>
|
|
|
|
|
|
{receivingPorts > 0 && (
|
|
|
|
|
|
<span style={{
|
|
|
|
|
|
fontSize: 11, fontWeight: 700, padding: '3px 8px', borderRadius: 4,
|
|
|
|
|
|
background: 'rgba(45,212,168,0.15)', color: 'var(--success)',
|
|
|
|
|
|
animation: 'signalPulse 1.4s ease-in-out infinite',
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{receivingPorts}/{totalPorts} LIVE
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{lastPoll && (
|
|
|
|
|
|
<span style={{ fontSize: 10.5, color: 'var(--text-4)', fontFamily: 'var(--font-mono)' }}>
|
|
|
|
|
|
updated {lastPoll.toLocaleTimeString()}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-22 10:55:19 -04:00
|
|
|
|
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="page-body">
|
2026-05-27 09:53:09 -04:00
|
|
|
|
{totalPorts === 0 ? (
|
|
|
|
|
|
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
|
|
|
|
|
|
No DeckLink devices found in cluster.
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
2026-05-27 09:53:09 -04:00
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
|
|
|
|
{nodeGroups.map(node => (
|
|
|
|
|
|
<CaptureNodeCard
|
|
|
|
|
|
key={node.node_id || node.hostname}
|
|
|
|
|
|
node={node}
|
|
|
|
|
|
ports={node.ports}
|
|
|
|
|
|
portSignals={portSignals}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 10:07:13 -04:00
|
|
|
|
/* ===== Monitors ===== */
|
2026-05-22 08:20:15 -04:00
|
|
|
|
function Monitors({ navigate }) {
|
2026-05-23 00:17:36 -04:00
|
|
|
|
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
|
2026-05-22 08:20:15 -04:00
|
|
|
|
const [grid, setGrid] = React.useState(4);
|
2026-05-22 10:07:13 -04:00
|
|
|
|
|
2026-05-22 10:55:19 -04:00
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
const refresh = () => {
|
|
|
|
|
|
window.ZAMPP_API.fetch('/recorders')
|
|
|
|
|
|
.then(raw => {
|
|
|
|
|
|
const norm = (raw || []).map(_normRecorder);
|
|
|
|
|
|
window.ZAMPP_DATA.RECORDERS = norm;
|
|
|
|
|
|
setRecorders(norm);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
};
|
2026-05-22 11:35:13 -04:00
|
|
|
|
refresh();
|
2026-05-22 10:55:19 -04:00
|
|
|
|
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' }));
|
2026-05-22 11:10:00 -04:00
|
|
|
|
const allFeeds = [...videoFeeds.map(r => ({ ...r, kind: 'video' })), ...audioFeeds];
|
2026-05-22 10:07:13 -04:00
|
|
|
|
const feeds = allFeeds.slice(0, grid * grid);
|
2026-05-22 08:20:15 -04:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="page">
|
|
|
|
|
|
<div className="page-header">
|
|
|
|
|
|
<h1>Monitors</h1>
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<span className="subtitle">Multi-cam live monitoring</span>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
<div className="spacer" />
|
|
|
|
|
|
<div className="tab-group">
|
|
|
|
|
|
{[2, 3, 4].map(n => (
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<button key={n} className={grid === n ? 'active' : ''} onClick={() => setGrid(n)}>{n}×{n}</button>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="page-body">
|
2026-05-22 10:55:19 -04:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function MonitorTile({ feed, seed }) {
|
2026-05-22 10:55:19 -04:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-05-22 10:07:13 -04:00
|
|
|
|
if (feed.kind === 'audio') {
|
2026-05-22 08:20:15 -04:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="monitor-tile audio">
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<div style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24 }}>
|
2026-05-22 08:20:15 -04:00
|
|
|
|
<Waveform seed={seed * 7} color="var(--accent)" />
|
|
|
|
|
|
</div>
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<div style={{ display: 'flex', gap: 4, padding: '0 16px 16px', justifyContent: 'center' }}>
|
2026-05-22 10:55:19 -04:00
|
|
|
|
<AudioMeter level={levels[0]} vertical />
|
|
|
|
|
|
<AudioMeter level={levels[1]} vertical />
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="monitor-tile-label">
|
|
|
|
|
|
<span className="badge live">LIVE</span>
|
|
|
|
|
|
<span className="name">{feed.name}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-22 10:55:19 -04:00
|
|
|
|
|
2026-05-22 08:20:15 -04:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="monitor-tile">
|
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
|
|
|
|
{isLive && feed.live_asset_id
|
|
|
|
|
|
? <HlsPreview assetId={feed.live_asset_id} />
|
|
|
|
|
|
: <FauxFrame />}
|
2026-05-22 11:10:00 -04:00
|
|
|
|
{isLive && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}
|
2026-05-22 10:07:13 -04:00
|
|
|
|
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
|
2026-05-22 11:10:00 -04:00
|
|
|
|
{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>}
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
2026-05-22 10:55:19 -04:00
|
|
|
|
{isLive && (
|
|
|
|
|
|
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}>
|
|
|
|
|
|
<AudioMeter level={levels[0]} vertical />
|
|
|
|
|
|
<AudioMeter level={levels[1]} vertical />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-22 08:20:15 -04:00
|
|
|
|
<div className="monitor-tile-label">
|
|
|
|
|
|
<span className="name">{feed.name}</span>
|
2026-05-22 10:07:13 -04:00
|
|
|
|
{feed.elapsed && feed.elapsed !== '—' && <span className="time mono">{feed.elapsed}</span>}
|
2026-05-22 08:20:15 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
/* ===== 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
// ── EPG (timeline) helpers ───────────────────────────────────────────────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// The Schedule screen is a broadcast-control-room timeline: recorders are
|
|
|
|
|
|
// rows, time is the horizontal axis. Helpers below convert between Date and
|
|
|
|
|
|
// "minutes into local day" so we can position absolute-positioned event
|
|
|
|
|
|
// blocks against a fixed --epg-pph (pixels-per-hour) CSS variable.
|
|
|
|
|
|
//
|
|
|
|
|
|
function _dayStart(d) {
|
|
|
|
|
|
const x = new Date(d);
|
|
|
|
|
|
x.setHours(0, 0, 0, 0);
|
|
|
|
|
|
return x;
|
2026-05-23 14:52:04 -04:00
|
|
|
|
}
|
2026-05-23 15:48:09 -04:00
|
|
|
|
function _dayEnd(d) {
|
|
|
|
|
|
const x = _dayStart(d);
|
|
|
|
|
|
x.setDate(x.getDate() + 1);
|
|
|
|
|
|
return x;
|
|
|
|
|
|
}
|
|
|
|
|
|
function _addDays(d, n) {
|
|
|
|
|
|
const x = new Date(d);
|
|
|
|
|
|
x.setDate(x.getDate() + n);
|
|
|
|
|
|
return x;
|
|
|
|
|
|
}
|
|
|
|
|
|
function _minutesIntoDay(date, dayStart) {
|
|
|
|
|
|
return Math.max(0, Math.min(24 * 60, (date - dayStart) / 60000));
|
|
|
|
|
|
}
|
|
|
|
|
|
function _eventOverlapsDay(ev, dayStart, dayEnd) {
|
|
|
|
|
|
const s = new Date(ev.start_at);
|
|
|
|
|
|
const e = new Date(ev.end_at);
|
|
|
|
|
|
return s < dayEnd && e > dayStart;
|
2026-05-23 14:52:04 -04:00
|
|
|
|
}
|
|
|
|
|
|
function _sameDay(a, b) {
|
|
|
|
|
|
return a.getFullYear() === b.getFullYear()
|
|
|
|
|
|
&& a.getMonth() === b.getMonth()
|
|
|
|
|
|
&& a.getDate() === b.getDate();
|
|
|
|
|
|
}
|
2026-05-23 15:48:09 -04:00
|
|
|
|
function _fmtDay(d) {
|
|
|
|
|
|
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
|
|
|
|
|
}
|
2026-05-23 14:52:04 -04:00
|
|
|
|
function _fmtTime(d) {
|
|
|
|
|
|
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
|
|
|
|
}
|
2026-05-23 15:48:09 -04:00
|
|
|
|
function _fmtHour(h) {
|
|
|
|
|
|
// 0 → "12 AM", 12 → "12 PM", 18 → "6 PM"
|
|
|
|
|
|
const ampm = h < 12 ? 'AM' : 'PM';
|
|
|
|
|
|
const hr = ((h + 11) % 12) + 1;
|
|
|
|
|
|
return hr + ' ' + ampm;
|
|
|
|
|
|
}
|
|
|
|
|
|
function _fmtCountdown(ms) {
|
|
|
|
|
|
if (ms <= 0) return 'now';
|
|
|
|
|
|
const s = Math.floor(ms / 1000);
|
|
|
|
|
|
if (s < 60) return 'in ' + s + 's';
|
|
|
|
|
|
const m = Math.floor(s / 60);
|
|
|
|
|
|
if (m < 60) return 'in ' + m + 'm';
|
|
|
|
|
|
const h = Math.floor(m / 60);
|
|
|
|
|
|
return 'in ' + h + 'h ' + (m % 60) + 'm';
|
|
|
|
|
|
}
|
|
|
|
|
|
function _fmtElapsed(ms) {
|
|
|
|
|
|
if (ms < 0) ms = 0;
|
|
|
|
|
|
const s = Math.floor(ms / 1000);
|
|
|
|
|
|
return String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
|
|
|
|
|
|
String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' +
|
|
|
|
|
|
String(s % 60).padStart(2, '0');
|
|
|
|
|
|
}
|
2026-05-23 14:52:04 -04:00
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
// Pick a stable color for a project_id given the global PROJECTS list.
|
|
|
|
|
|
function _projectColor(projectId, projects) {
|
|
|
|
|
|
if (!projectId) return null;
|
|
|
|
|
|
const p = (projects || []).find(p => p.id === projectId);
|
|
|
|
|
|
return p?.color || null;
|
|
|
|
|
|
}
|
2026-05-23 14:52:04 -04:00
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
// ── EPG components ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function _StatusStrip({ schedules, recorders, now, projects }) {
|
|
|
|
|
|
// What's recording: any schedule whose window contains `now` AND whose
|
|
|
|
|
|
// status is 'running'. (Manually-started recorders without a schedule
|
|
|
|
|
|
// surface on the Recorders screen; the schedule strip stays focused on
|
|
|
|
|
|
// planned events so the operator can trust it.)
|
|
|
|
|
|
const active = (schedules || []).filter(s => {
|
|
|
|
|
|
const start = new Date(s.start_at);
|
|
|
|
|
|
const end = new Date(s.end_at);
|
|
|
|
|
|
return start <= now && now < end && (s.status === 'running' || s.status === 'pending');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Next up: earliest pending schedule strictly in the future.
|
|
|
|
|
|
const upcoming = (schedules || [])
|
|
|
|
|
|
.filter(s => s.status === 'pending' && new Date(s.start_at) > now)
|
|
|
|
|
|
.sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
|
|
|
|
|
|
const next = upcoming[0];
|
|
|
|
|
|
|
|
|
|
|
|
const recMap = {};
|
|
|
|
|
|
(recorders || []).forEach(r => { recMap[r.id] = r; });
|
2026-05-23 14:52:04 -04:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-23 15:48:09 -04:00
|
|
|
|
<div className="epg-status">
|
|
|
|
|
|
<div className="epg-status-row">
|
|
|
|
|
|
{active.length === 0 ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="epg-status-dot idle" />
|
|
|
|
|
|
<span className="epg-status-label">Nothing scheduled right now</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="epg-status-dot live" />
|
|
|
|
|
|
<span className="epg-status-label">On air</span>
|
|
|
|
|
|
<div className="epg-status-active">
|
|
|
|
|
|
{active.map(s => {
|
|
|
|
|
|
const rec = recMap[s.recorder_id];
|
|
|
|
|
|
const elapsed = _fmtElapsed(now - new Date(s.start_at));
|
|
|
|
|
|
const endsAt = _fmtTime(new Date(s.end_at));
|
|
|
|
|
|
const color = _projectColor(rec?.project_id, projects);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span key={s.id} className="epg-status-pill">
|
|
|
|
|
|
{color && <span className="epg-status-pill-bar" style={{ background: color }} />}
|
|
|
|
|
|
<span className="epg-status-pill-name">{s.name}</span>
|
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-26 22:06:14 -04:00
|
|
|
|
<span className="epg-status-pill-rec mono">{rec?.name || (s.recorder_id ? s.recorder_id.slice(0, 8) : 'unassigned')}</span>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
<span className="epg-status-pill-time mono">{elapsed} · ends {endsAt}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-05-23 14:52:04 -04:00
|
|
|
|
</div>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="epg-status-row sub">
|
|
|
|
|
|
{next ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="epg-status-label muted">Next up</span>
|
|
|
|
|
|
<span className="epg-status-next">{next.name}</span>
|
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-26 22:06:14 -04:00
|
|
|
|
<span className="epg-status-next-rec mono">{recMap[next.recorder_id]?.name || (next.recorder_id ? next.recorder_id.slice(0, 8) : 'unassigned')}</span>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
<span className="epg-status-next-time mono">{_fmtCountdown(new Date(next.start_at) - now)} · {_fmtTime(new Date(next.start_at))}</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="epg-status-label muted">No upcoming schedules</span>
|
|
|
|
|
|
)}
|
2026-05-23 14:52:04 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
function _EpgRuler({ pph }) {
|
|
|
|
|
|
// 25 ticks so the last one (24:00) labels the right edge. The 24h column
|
|
|
|
|
|
// ends at 24*pph; the 25th tick is purely a label and has zero width.
|
|
|
|
|
|
const hours = [];
|
|
|
|
|
|
for (let h = 0; h <= 24; h++) hours.push(h);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="epg-ruler" style={{ width: 24 * pph }}>
|
|
|
|
|
|
{hours.map(h => (
|
|
|
|
|
|
<div key={h} className={'epg-ruler-tick ' + (h === 24 ? 'end' : '')}
|
|
|
|
|
|
style={{ left: h * pph }}>
|
|
|
|
|
|
<span>{h === 24 ? '' : _fmtHour(h)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 16:33:57 -04:00
|
|
|
|
// Minimum schedule length the UI permits while dragging. Anything shorter
|
|
|
|
|
|
// rarely reflects a real plan and would let the operator accidentally
|
|
|
|
|
|
// dismiss a block to zero width.
|
|
|
|
|
|
const _EPG_MIN_MS = 5 * 60 * 1000;
|
|
|
|
|
|
// Drag snap quantum. Mirrors the new-schedule click snap so a resized
|
|
|
|
|
|
// block lines up to the same grid an operator just placed a new one on.
|
|
|
|
|
|
const _EPG_SNAP_MIN = 15;
|
|
|
|
|
|
// Pointer travel (in px) before we treat the gesture as a drag rather
|
|
|
|
|
|
// than a click. Below this, pointerup fires the click handler.
|
|
|
|
|
|
const _EPG_DRAG_THRESHOLD = 4;
|
|
|
|
|
|
|
|
|
|
|
|
function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick, onContextMenu, onResize }) {
|
|
|
|
|
|
// Drag state: null when idle, otherwise the in-flight resize/move
|
|
|
|
|
|
// describing the original times and the current (snapped) preview times.
|
|
|
|
|
|
// We render from this preview while dragging so the block follows the
|
|
|
|
|
|
// cursor without round-tripping through props.
|
|
|
|
|
|
const [drag, setDrag] = React.useState(null);
|
|
|
|
|
|
const blockRef = React.useRef(null);
|
|
|
|
|
|
|
|
|
|
|
|
const eventStartMs = new Date(event.start_at).getTime();
|
|
|
|
|
|
const eventEndMs = new Date(event.end_at).getTime();
|
|
|
|
|
|
const isLive = event.status === 'running' || (event.status === 'pending' && eventStartMs <= now.getTime() && now.getTime() < eventEndMs);
|
2026-05-23 15:48:09 -04:00
|
|
|
|
const isFailed = event.status === 'failed';
|
2026-05-23 16:33:57 -04:00
|
|
|
|
const isPast = (event.status === 'completed' || event.status === 'cancelled') || eventEndMs < now.getTime();
|
2026-05-23 15:48:09 -04:00
|
|
|
|
const color = _projectColor(recorder?.project_id, projects);
|
|
|
|
|
|
|
2026-05-23 16:33:57 -04:00
|
|
|
|
// Only pending schedules can be resized. The API rejects PUTs against
|
|
|
|
|
|
// running schedules outright; cancelling them is what an operator
|
|
|
|
|
|
// actually wants there. Terminal statuses are read-only.
|
|
|
|
|
|
const canDrag = event.status === 'pending';
|
|
|
|
|
|
|
|
|
|
|
|
const startDrag = (e, type) => {
|
|
|
|
|
|
if (!canDrag) return;
|
|
|
|
|
|
if (e.button !== 0) return; // ignore right-click
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
try { blockRef.current.setPointerCapture(e.pointerId); } catch (_) {}
|
|
|
|
|
|
setDrag({
|
|
|
|
|
|
type, pointerId: e.pointerId,
|
|
|
|
|
|
startX: e.clientX,
|
|
|
|
|
|
origStart: eventStartMs, origEnd: eventEndMs,
|
|
|
|
|
|
currStart: eventStartMs, currEnd: eventEndMs,
|
|
|
|
|
|
moved: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const onPointerMove = (ev) => {
|
|
|
|
|
|
if (!drag) return;
|
|
|
|
|
|
const dx = ev.clientX - drag.startX;
|
|
|
|
|
|
if (!drag.moved && Math.abs(dx) < _EPG_DRAG_THRESHOLD) return;
|
|
|
|
|
|
const snapMs = _EPG_SNAP_MIN * 60 * 1000;
|
|
|
|
|
|
const dMs = Math.round((dx / pph) * 3600 * 1000 / snapMs) * snapMs;
|
|
|
|
|
|
const dayStartMs = dayStart.getTime();
|
|
|
|
|
|
const dayEndMs = dayEnd.getTime();
|
|
|
|
|
|
let cs = drag.origStart, ce = drag.origEnd;
|
|
|
|
|
|
if (drag.type === 'left') {
|
|
|
|
|
|
cs = Math.max(dayStartMs, Math.min(drag.origEnd - _EPG_MIN_MS, drag.origStart + dMs));
|
|
|
|
|
|
} else if (drag.type === 'right') {
|
|
|
|
|
|
ce = Math.min(dayEndMs, Math.max(drag.origStart + _EPG_MIN_MS, drag.origEnd + dMs));
|
|
|
|
|
|
} else if (drag.type === 'body') {
|
|
|
|
|
|
cs = drag.origStart + dMs;
|
|
|
|
|
|
ce = drag.origEnd + dMs;
|
|
|
|
|
|
// Clamp to the day; preserve duration when bumping against an edge.
|
|
|
|
|
|
if (cs < dayStartMs) { ce += (dayStartMs - cs); cs = dayStartMs; }
|
|
|
|
|
|
if (ce > dayEndMs) { cs -= (ce - dayEndMs); ce = dayEndMs; }
|
|
|
|
|
|
}
|
|
|
|
|
|
setDrag({ ...drag, currStart: cs, currEnd: ce, moved: true });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const endDrag = (ev) => {
|
|
|
|
|
|
if (!drag) return;
|
|
|
|
|
|
try { blockRef.current.releasePointerCapture(drag.pointerId); } catch (_) {}
|
|
|
|
|
|
const d = drag;
|
|
|
|
|
|
setDrag(null);
|
|
|
|
|
|
if (!d.moved) {
|
|
|
|
|
|
// Treat as a click — open the edit modal.
|
|
|
|
|
|
onClick(event);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (d.currStart === d.origStart && d.currEnd === d.origEnd) return;
|
|
|
|
|
|
onResize(event, new Date(d.currStart).toISOString(), new Date(d.currEnd).toISOString());
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Render from drag preview while a gesture is in flight so the block
|
|
|
|
|
|
// tracks the pointer; otherwise from the canonical event prop.
|
|
|
|
|
|
const dispStartMs = drag ? drag.currStart : eventStartMs;
|
|
|
|
|
|
const dispEndMs = drag ? drag.currEnd : eventEndMs;
|
|
|
|
|
|
const dispStart = new Date(dispStartMs);
|
|
|
|
|
|
const dispEnd = new Date(dispEndMs);
|
|
|
|
|
|
const startMin = _minutesIntoDay(dispStart, dayStart);
|
|
|
|
|
|
const endMin = _minutesIntoDay(dispEnd, dayStart);
|
|
|
|
|
|
const left = (startMin / 60) * pph;
|
|
|
|
|
|
const width = Math.max(40, ((endMin - startMin) / 60) * pph);
|
|
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
const classes = ['epg-block'];
|
2026-05-23 16:33:57 -04:00
|
|
|
|
if (isLive) classes.push('live');
|
|
|
|
|
|
if (isFailed) classes.push('failed');
|
2026-05-23 15:48:09 -04:00
|
|
|
|
else if (isPast) classes.push('past');
|
2026-05-23 16:33:57 -04:00
|
|
|
|
if (drag && drag.moved) classes.push('dragging');
|
|
|
|
|
|
if (canDrag) classes.push('resizable');
|
2026-05-23 15:48:09 -04:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-23 16:33:57 -04:00
|
|
|
|
<div
|
|
|
|
|
|
ref={blockRef}
|
2026-05-23 15:48:09 -04:00
|
|
|
|
className={classes.join(' ')}
|
|
|
|
|
|
style={{ left, width, '--epg-block-color': color || 'var(--text-3)' }}
|
2026-05-23 16:33:57 -04:00
|
|
|
|
onContextMenu={(ev) => { ev.preventDefault(); ev.stopPropagation(); onContextMenu(event, ev); }}
|
|
|
|
|
|
onPointerMove={onPointerMove}
|
|
|
|
|
|
onPointerUp={endDrag}
|
|
|
|
|
|
onPointerCancel={endDrag}
|
|
|
|
|
|
title={event.name + ' · ' + _fmtTime(dispStart) + ' → ' + _fmtTime(dispEnd) + (event.error_message ? ' · ' + event.error_message : '')}>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
<span className="epg-block-bar" />
|
2026-05-23 16:33:57 -04:00
|
|
|
|
{/* Body click → edit, body drag → move. We hang the click on pointerup
|
|
|
|
|
|
so the threshold check above can demote a drag back to a click. */}
|
|
|
|
|
|
<div className="epg-block-body" onPointerDown={(ev) => startDrag(ev, 'body')}>
|
|
|
|
|
|
<span className="epg-block-name">{event.name}</span>
|
|
|
|
|
|
<span className="epg-block-time mono">{_fmtTime(dispStart)}{drag && drag.moved ? ' → ' + _fmtTime(dispEnd) : ''}</span>
|
|
|
|
|
|
{isLive && <span className="epg-block-glyph live" title="on air">●</span>}
|
|
|
|
|
|
{isFailed && <span className="epg-block-glyph failed" title={event.error_message || 'failed'}>!</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{canDrag && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="epg-block-handle left"
|
|
|
|
|
|
onPointerDown={(ev) => startDrag(ev, 'left')}
|
|
|
|
|
|
title="Drag to change start time" />
|
|
|
|
|
|
<span className="epg-block-handle right"
|
|
|
|
|
|
onPointerDown={(ev) => startDrag(ev, 'right')}
|
|
|
|
|
|
title="Drag to change end time" />
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 16:33:57 -04:00
|
|
|
|
function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, onEventClick, onEmptyClick, onEventContextMenu, onEventResize }) {
|
2026-05-23 15:48:09 -04:00
|
|
|
|
const dayEvents = (schedules || []).filter(s => s.recorder_id === recorder.id && _eventOverlapsDay(s, dayStart, dayEnd));
|
|
|
|
|
|
|
2026-05-23 16:33:57 -04:00
|
|
|
|
const handleRowPointerUp = (e) => {
|
|
|
|
|
|
// Open the new-schedule modal only on a real click in the empty
|
|
|
|
|
|
// gutter. Clicks on event blocks stopPropagation themselves; we also
|
|
|
|
|
|
// guard against the tail of a block-drag bubbling up here.
|
|
|
|
|
|
if (e.target !== e.currentTarget) return;
|
|
|
|
|
|
if (e.button !== 0) return;
|
2026-05-23 15:48:09 -04:00
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
|
|
|
|
const x = e.clientX - rect.left;
|
2026-05-23 16:33:57 -04:00
|
|
|
|
const minutes = Math.max(0, Math.min(24 * 60 - 30, Math.round((x / pph) * 60 / _EPG_SNAP_MIN) * _EPG_SNAP_MIN));
|
2026-05-23 15:48:09 -04:00
|
|
|
|
const start = new Date(dayStart);
|
|
|
|
|
|
start.setMinutes(minutes);
|
|
|
|
|
|
onEmptyClick(recorder, start);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-23 16:33:57 -04:00
|
|
|
|
<div className="epg-row" style={{ width: 24 * pph }} onPointerUp={handleRowPointerUp}>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
{dayEvents.map(s => (
|
|
|
|
|
|
<_EventBlock
|
|
|
|
|
|
key={s.id}
|
|
|
|
|
|
event={s}
|
|
|
|
|
|
recorder={recorder}
|
|
|
|
|
|
dayStart={dayStart}
|
|
|
|
|
|
dayEnd={dayEnd}
|
|
|
|
|
|
pph={pph}
|
|
|
|
|
|
now={now}
|
|
|
|
|
|
projects={projects}
|
2026-05-23 16:33:57 -04:00
|
|
|
|
onClick={onEventClick}
|
|
|
|
|
|
onContextMenu={onEventContextMenu}
|
|
|
|
|
|
onResize={onEventResize} />
|
2026-05-23 15:48:09 -04:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 16:33:57 -04:00
|
|
|
|
// ── Right-click menu for an EPG event block ─────────────────────────────────
|
|
|
|
|
|
// Same pattern as AssetContextMenu (screens-library.jsx): viewport-clamped,
|
|
|
|
|
|
// dismissed on outside click. Per-status action filtering mirrors the
|
|
|
|
|
|
// buttons rendered in the List view so the two surfaces stay consistent.
|
|
|
|
|
|
function _ScheduleContextMenu({ schedule, x, y, onClose, onEdit, onCancel, onDelete, onCopyId }) {
|
|
|
|
|
|
const ref = React.useRef(null);
|
|
|
|
|
|
const [pos, setPos] = React.useState({ left: x, top: y });
|
|
|
|
|
|
React.useLayoutEffect(() => {
|
|
|
|
|
|
if (!ref.current) return;
|
|
|
|
|
|
const r = ref.current.getBoundingClientRect();
|
|
|
|
|
|
const margin = 8;
|
|
|
|
|
|
let nx = x, ny = y;
|
|
|
|
|
|
if (x + r.width + margin > window.innerWidth) nx = window.innerWidth - r.width - margin;
|
|
|
|
|
|
if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
|
|
|
|
|
|
setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
|
|
|
|
|
|
}, [x, y]);
|
|
|
|
|
|
|
|
|
|
|
|
const canEdit = schedule.status === 'pending' || schedule.status === 'failed';
|
|
|
|
|
|
const canCancel = schedule.status === 'pending' || schedule.status === 'running';
|
|
|
|
|
|
const canDelete = schedule.status !== 'running';
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
|
|
|
|
|
<div className="ctx-header">{schedule.name}</div>
|
|
|
|
|
|
{canEdit && <button onClick={onEdit}><Icon name="edit" size={11} />Edit…</button>}
|
|
|
|
|
|
{canCancel && <button onClick={onCancel}><Icon name="x" size={11} />Cancel run</button>}
|
|
|
|
|
|
<button onClick={onCopyId}><Icon name="library" size={11} />Copy schedule ID</button>
|
|
|
|
|
|
{canDelete && <div className="ctx-divider" />}
|
|
|
|
|
|
{canDelete && <button className="danger" onClick={onDelete}><Icon name="trash" size={11} />Delete schedule</button>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
function _NowLine({ now, dayStart, pph }) {
|
|
|
|
|
|
if (!_sameDay(now, dayStart)) return null;
|
|
|
|
|
|
const min = _minutesIntoDay(now, dayStart);
|
|
|
|
|
|
const x = (min / 60) * pph;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="epg-now" style={{ left: x }}>
|
|
|
|
|
|
<span className="epg-now-pip" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _RecorderGutter({ recorders, projects }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="epg-gutter-rows">
|
|
|
|
|
|
{recorders.map(r => {
|
|
|
|
|
|
const color = _projectColor(r.project_id, projects);
|
|
|
|
|
|
const isLive = r.status === 'recording';
|
|
|
|
|
|
const isErr = r.status === 'error';
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={r.id} className="epg-gutter-row">
|
|
|
|
|
|
<span className={'epg-gutter-status ' + (isLive ? 'live' : isErr ? 'err' : 'idle')} />
|
|
|
|
|
|
<div className="epg-gutter-meta">
|
|
|
|
|
|
<div className="epg-gutter-name">{r.name}</div>
|
|
|
|
|
|
<div className="epg-gutter-sub mono">{(r.source_type || '—').toUpperCase()}{color ? ' · ' : ''}{color && <span className="epg-gutter-dot" style={{ background: color }} />}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
function Schedule({ navigate }) {
|
2026-05-23 15:48:09 -04:00
|
|
|
|
const [schedules, setSchedules] = React.useState(null);
|
|
|
|
|
|
const [recorders, setRecorders] = React.useState([]);
|
|
|
|
|
|
const [showNew, setShowNew] = React.useState(false);
|
|
|
|
|
|
const [newDefaults, setNewDefaults] = React.useState(null);
|
|
|
|
|
|
const [editing, setEditing] = React.useState(null);
|
2026-05-23 16:33:57 -04:00
|
|
|
|
const [ctxMenu, setCtxMenu] = React.useState(null); // { schedule, x, y }
|
2026-05-23 15:48:09 -04:00
|
|
|
|
const [view, setView] = React.useState('today'); // 'today' | 'week' | 'list'
|
|
|
|
|
|
const [day, setDay] = React.useState(() => _dayStart(new Date()));
|
|
|
|
|
|
const [listFilter, setListFilter] = React.useState('upcoming');
|
|
|
|
|
|
const [now, setNow] = React.useState(() => new Date());
|
|
|
|
|
|
|
|
|
|
|
|
// Tick the now-line every second. We only re-render the components that
|
|
|
|
|
|
// consume `now`; the rest are React.memo or insensitive.
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
const id = setInterval(() => setNow(new Date()), 1000);
|
|
|
|
|
|
return () => clearInterval(id);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// Schedule data — pull everything once and filter client-side for the
|
|
|
|
|
|
// active view. /schedules caps at 200 rows so this stays cheap.
|
|
|
|
|
|
const apiFilter = view === 'list' ? listFilter : 'all';
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
const load = React.useCallback(() => {
|
2026-05-23 14:52:04 -04:00
|
|
|
|
window.ZAMPP_API.fetch('/schedules?status=' + apiFilter)
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
.then(d => setSchedules(d.schedules || []))
|
|
|
|
|
|
.catch(() => setSchedules([]));
|
2026-05-23 14:52:04 -04:00
|
|
|
|
}, [apiFilter]);
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => { load(); }, [load]);
|
2026-05-23 15:48:09 -04:00
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([]));
|
|
|
|
|
|
}, []);
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
// Auto-refresh: 10s in normal view, 4s if anything is live so the now-state
|
|
|
|
|
|
// catches transitions promptly.
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
React.useEffect(() => {
|
2026-05-23 15:48:09 -04:00
|
|
|
|
const anyLive = (schedules || []).some(s => s.status === 'running');
|
|
|
|
|
|
const id = setInterval(load, anyLive ? 4000 : 10_000);
|
|
|
|
|
|
return () => clearInterval(id);
|
|
|
|
|
|
}, [load, schedules]);
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
// The Recorders screen broadcasts these on create/delete; refresh the
|
|
|
|
|
|
// gutter so renamed or new recorders show up immediately.
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
const refresh = () => window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => {});
|
|
|
|
|
|
window.addEventListener('df:recorders-changed', refresh);
|
|
|
|
|
|
return () => window.removeEventListener('df:recorders-changed', refresh);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const projects = window.ZAMPP_DATA?.PROJECTS || [];
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
// Pixels per hour — wider on Today (high-res operations view), tighter
|
|
|
|
|
|
// when the user is scanning Week-at-a-glance.
|
|
|
|
|
|
const pph = view === 'week' ? 44 : 88;
|
|
|
|
|
|
|
|
|
|
|
|
const dayStart = _dayStart(day);
|
|
|
|
|
|
const dayEnd = _dayEnd(day);
|
|
|
|
|
|
|
|
|
|
|
|
// Scroll the canvas so "now" sits ~30% from the left edge on first paint
|
|
|
|
|
|
// for Today view. Re-runs when the user jumps days via the Today button.
|
|
|
|
|
|
const canvasRef = React.useRef(null);
|
|
|
|
|
|
React.useLayoutEffect(() => {
|
|
|
|
|
|
if (view !== 'today' || !canvasRef.current) return;
|
|
|
|
|
|
if (!_sameDay(now, dayStart)) {
|
|
|
|
|
|
canvasRef.current.scrollLeft = 0;
|
|
|
|
|
|
return;
|
2026-05-23 14:52:04 -04:00
|
|
|
|
}
|
2026-05-23 15:48:09 -04:00
|
|
|
|
const min = _minutesIntoDay(now, dayStart);
|
|
|
|
|
|
const x = (min / 60) * pph;
|
|
|
|
|
|
const target = Math.max(0, x - canvasRef.current.clientWidth * 0.3);
|
|
|
|
|
|
canvasRef.current.scrollLeft = target;
|
|
|
|
|
|
// Deliberately only re-run on view/day change, not on `now` ticking.
|
|
|
|
|
|
// Otherwise the canvas would re-scroll every second and trap the user.
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [view, day, pph, recorders.length]);
|
|
|
|
|
|
|
|
|
|
|
|
const openNewAt = (recorder, start) => {
|
2026-05-23 14:52:04 -04:00
|
|
|
|
const end = new Date(start.getTime() + 30 * 60 * 1000);
|
2026-05-23 15:48:09 -04:00
|
|
|
|
setNewDefaults({ start, end, recorder_id: recorder.id });
|
2026-05-23 14:52:04 -04:00
|
|
|
|
setShowNew(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
const openNewBlank = () => { setNewDefaults(null); setShowNew(true); };
|
|
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
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));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-23 16:33:57 -04:00
|
|
|
|
// Drag-resize commit: optimistically patch the in-memory schedule so the
|
|
|
|
|
|
// block stays put after the user lets go, then PUT the new times. The
|
|
|
|
|
|
// refetch reconciles in case the server adjusted anything (or rejected).
|
|
|
|
|
|
const handleResize = (s, newStart, newEnd) => {
|
|
|
|
|
|
setSchedules(prev => prev ? prev.map(x => x.id === s.id ? { ...x, start_at: newStart, end_at: newEnd } : x) : prev);
|
|
|
|
|
|
window.ZAMPP_API.fetch('/schedules/' + s.id, {
|
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
body: JSON.stringify({ start_at: newStart, end_at: newEnd }),
|
|
|
|
|
|
})
|
|
|
|
|
|
.then(load)
|
|
|
|
|
|
.catch(e => { alert('Resize failed: ' + e.message); load(); });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const openCtx = (s, ev) => setCtxMenu({ schedule: s, x: ev.clientX, y: ev.clientY });
|
|
|
|
|
|
// Dismiss the context menu on any outside click — capture phase so a
|
|
|
|
|
|
// click on a menu item still fires before the menu unmounts.
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
if (!ctxMenu) return;
|
|
|
|
|
|
const close = () => setCtxMenu(null);
|
|
|
|
|
|
window.addEventListener('click', close);
|
|
|
|
|
|
window.addEventListener('contextmenu', close);
|
|
|
|
|
|
window.addEventListener('scroll', close, true);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
window.removeEventListener('click', close);
|
|
|
|
|
|
window.removeEventListener('contextmenu', close);
|
|
|
|
|
|
window.removeEventListener('scroll', close, true);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [ctxMenu]);
|
|
|
|
|
|
|
|
|
|
|
|
const copyId = (id) => {
|
|
|
|
|
|
if (navigator.clipboard) navigator.clipboard.writeText(id).catch(() => {});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
// Days for Week view: the 7-day window starting at the Sunday of `day`.
|
|
|
|
|
|
const weekDays = React.useMemo(() => {
|
|
|
|
|
|
const sun = _addDays(dayStart, -dayStart.getDay());
|
|
|
|
|
|
return Array.from({ length: 7 }, (_, i) => _addDays(sun, i));
|
|
|
|
|
|
}, [dayStart]);
|
|
|
|
|
|
|
|
|
|
|
|
// List view filters schedules by client-side time bucket too.
|
|
|
|
|
|
const listSchedules = React.useMemo(() => {
|
|
|
|
|
|
if (!schedules) return null;
|
|
|
|
|
|
return [...schedules].sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
|
|
|
|
|
|
}, [schedules]);
|
2026-05-23 14:52:04 -04:00
|
|
|
|
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
return (
|
2026-05-23 15:48:09 -04:00
|
|
|
|
<div className="epg-page" style={{ '--epg-pph': pph + 'px' }}>
|
|
|
|
|
|
<_StatusStrip schedules={schedules || []} recorders={recorders} now={now} projects={projects} />
|
|
|
|
|
|
|
|
|
|
|
|
<div className="epg-toolbar">
|
|
|
|
|
|
<div className="epg-date">
|
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-26 22:06:14 -04:00
|
|
|
|
<button className="icon-btn" onClick={() => setDay(_addDays(day, -1))} title="Previous day" aria-label="Previous day"><Icon name="chevron" style={{ transform: 'rotate(90deg)' }} /></button>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
<div className="epg-date-label">{_sameDay(day, new Date()) ? 'Today · ' + _fmtDay(day) : _fmtDay(day)}</div>
|
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-26 22:06:14 -04:00
|
|
|
|
<button className="icon-btn" onClick={() => setDay(_addDays(day, 1))} title="Next day" aria-label="Next day"><Icon name="chevron" style={{ transform: 'rotate(-90deg)' }} /></button>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
<button className="btn ghost sm" onClick={() => setDay(_dayStart(new Date()))} disabled={_sameDay(day, new Date())}>Today</button>
|
|
|
|
|
|
</div>
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
<div className="spacer" />
|
2026-05-23 15:48:09 -04:00
|
|
|
|
<div className="tab-group">
|
|
|
|
|
|
<button className={view === 'today' ? 'active' : ''} onClick={() => setView('today')}>Today</button>
|
|
|
|
|
|
<button className={view === 'week' ? 'active' : ''} onClick={() => setView('week')}>Week</button>
|
|
|
|
|
|
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')}>List</button>
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
|
2026-05-23 14:52:04 -04:00
|
|
|
|
<button className="btn primary" onClick={openNewBlank} disabled={recorders.length === 0}>
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
<Icon name="plus" />New schedule
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
{schedules === null && (
|
|
|
|
|
|
<div className="epg-empty">Loading…</div>
|
|
|
|
|
|
)}
|
2026-05-23 14:52:04 -04:00
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
{schedules !== null && recorders.length === 0 && (
|
|
|
|
|
|
<div className="epg-empty">
|
|
|
|
|
|
<div className="epg-empty-title">No recorders configured</div>
|
|
|
|
|
|
<div className="epg-empty-sub">Create a recorder before scheduling.</div>
|
|
|
|
|
|
<button className="btn primary sm" onClick={() => navigate('recorders')}>Go to Recorders</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{schedules !== null && recorders.length > 0 && view === 'today' && (
|
|
|
|
|
|
<div className="epg" ref={canvasRef}>
|
|
|
|
|
|
<div className="epg-corner">
|
|
|
|
|
|
<span className="mono">{_fmtDay(day)}</span>
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
</div>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
<div className="epg-gutter">
|
|
|
|
|
|
<_RecorderGutter recorders={recorders} projects={projects} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="epg-canvas-head">
|
|
|
|
|
|
<_EpgRuler pph={pph} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="epg-canvas" style={{ width: 24 * pph }}>
|
|
|
|
|
|
<div className="epg-rows">
|
|
|
|
|
|
{recorders.map(r => (
|
|
|
|
|
|
<_EpgRow key={r.id}
|
|
|
|
|
|
recorder={r}
|
|
|
|
|
|
schedules={schedules}
|
|
|
|
|
|
dayStart={dayStart}
|
|
|
|
|
|
dayEnd={dayEnd}
|
|
|
|
|
|
pph={pph}
|
|
|
|
|
|
now={now}
|
|
|
|
|
|
projects={projects}
|
|
|
|
|
|
onEventClick={(s) => setEditing(s)}
|
2026-05-23 16:33:57 -04:00
|
|
|
|
onEventContextMenu={openCtx}
|
|
|
|
|
|
onEventResize={handleResize}
|
2026-05-23 15:48:09 -04:00
|
|
|
|
onEmptyClick={openNewAt} />
|
|
|
|
|
|
))}
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
</div>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
<_NowLine now={now} dayStart={dayStart} pph={pph} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{schedules !== null && recorders.length > 0 && view === 'week' && (
|
|
|
|
|
|
<div className="epg-week">
|
|
|
|
|
|
{weekDays.map(d => {
|
|
|
|
|
|
const dEnd = _dayEnd(d);
|
|
|
|
|
|
const isToday = _sameDay(d, new Date());
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={d.toISOString()} className={'epg-week-day' + (isToday ? ' today' : '')}>
|
|
|
|
|
|
<div className="epg-week-dayhead">
|
|
|
|
|
|
<span className="epg-week-dayname">{_fmtDay(d)}</span>
|
|
|
|
|
|
{isToday && <span className="epg-week-todaypip">today</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="epg-week-row-wrap" style={{ width: 24 * pph }}>
|
|
|
|
|
|
<_EpgRuler pph={pph} />
|
|
|
|
|
|
<div className="epg-rows">
|
|
|
|
|
|
{recorders.map(r => (
|
|
|
|
|
|
<_EpgRow key={r.id}
|
|
|
|
|
|
recorder={r}
|
|
|
|
|
|
schedules={schedules}
|
|
|
|
|
|
dayStart={d}
|
|
|
|
|
|
dayEnd={dEnd}
|
|
|
|
|
|
pph={pph}
|
|
|
|
|
|
now={now}
|
|
|
|
|
|
projects={projects}
|
|
|
|
|
|
onEventClick={(s) => setEditing(s)}
|
2026-05-23 16:33:57 -04:00
|
|
|
|
onEventContextMenu={openCtx}
|
|
|
|
|
|
onEventResize={handleResize}
|
2026-05-23 15:48:09 -04:00
|
|
|
|
onEmptyClick={openNewAt} />
|
|
|
|
|
|
))}
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
</div>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
{isToday && <_NowLine now={now} dayStart={d} pph={pph} />}
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
</div>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{schedules !== null && recorders.length > 0 && view === 'list' && (
|
|
|
|
|
|
<div className="epg-list">
|
|
|
|
|
|
<div className="tab-group" style={{ marginBottom: 12 }}>
|
|
|
|
|
|
<button className={listFilter === 'upcoming' ? 'active' : ''} onClick={() => setListFilter('upcoming')}>Upcoming</button>
|
|
|
|
|
|
<button className={listFilter === 'past' ? 'active' : ''} onClick={() => setListFilter('past')}>Past</button>
|
|
|
|
|
|
<button className={listFilter === 'all' ? 'active' : ''} onClick={() => setListFilter('all')}>All</button>
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
</div>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
{(listSchedules || []).length === 0 ? (
|
|
|
|
|
|
<div className="epg-empty"><div className="epg-empty-title">No {listFilter} schedules</div></div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<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>
|
|
|
|
|
|
{listSchedules.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>
|
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-26 22:06:14 -04:00
|
|
|
|
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-2)' }}>{s.recorder_name || (s.recorder_id ? s.recorder_id.slice(0, 8) : 'unassigned')}</div>
|
2026-05-23 15:48:09 -04:00
|
|
|
|
<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)}>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)}>Delete</button>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
|
2026-05-23 14:52:04 -04:00
|
|
|
|
{showNew && <NewScheduleModal
|
|
|
|
|
|
recorders={recorders}
|
2026-05-23 15:48:09 -04:00
|
|
|
|
defaultRecorderId={newDefaults?.recorder_id}
|
2026-05-23 14:52:04 -04:00
|
|
|
|
defaultStart={newDefaults?.start}
|
|
|
|
|
|
defaultEnd={newDefaults?.end}
|
|
|
|
|
|
onClose={() => { setShowNew(false); setNewDefaults(null); }}
|
|
|
|
|
|
onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />}
|
polish: schedule edit + README refresh
- Schedule: pending rows get an 'Edit' button next to Cancel/Delete;
opens a modal that PUTs /schedules/:id with new name/times/recurrence
(recorder reassignment is intentionally locked — delete + recreate
to swap recorder)
- README rewritten: project renamed to dragonflight, full feature
catalog (ingest, growing-files, scheduler, library + comments,
jobs, settings, cluster), accurate ports, refreshed architecture
diagram, ops scripts inventory
2026-05-23 00:26:03 -04:00
|
|
|
|
{editing && <EditScheduleModal schedule={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load(); }} />}
|
2026-05-23 16:33:57 -04:00
|
|
|
|
{ctxMenu && (
|
|
|
|
|
|
<_ScheduleContextMenu
|
|
|
|
|
|
schedule={ctxMenu.schedule}
|
|
|
|
|
|
x={ctxMenu.x}
|
|
|
|
|
|
y={ctxMenu.y}
|
|
|
|
|
|
onClose={() => setCtxMenu(null)}
|
|
|
|
|
|
onEdit={() => { const s = ctxMenu.schedule; setCtxMenu(null); setEditing(s); }}
|
|
|
|
|
|
onCancel={() => { const s = ctxMenu.schedule; setCtxMenu(null); cancel(s); }}
|
|
|
|
|
|
onDelete={() => { const s = ctxMenu.schedule; setCtxMenu(null); remove(s); }}
|
|
|
|
|
|
onCopyId={() => { const id = ctxMenu.schedule.id; setCtxMenu(null); copyId(id); }} />
|
|
|
|
|
|
)}
|
polish: schedule edit + README refresh
- Schedule: pending rows get an 'Edit' button next to Cancel/Delete;
opens a modal that PUTs /schedules/:id with new name/times/recurrence
(recorder reassignment is intentionally locked — delete + recreate
to swap recorder)
- README rewritten: project renamed to dragonflight, full feature
catalog (ingest, growing-files, scheduler, library + comments,
jobs, settings, cluster), accurate ports, refreshed architecture
diagram, ops scripts inventory
2026-05-23 00:26:03 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
|
polish: schedule edit + README refresh
- Schedule: pending rows get an 'Edit' button next to Cancel/Delete;
opens a modal that PUTs /schedules/:id with new name/times/recurrence
(recorder reassignment is intentionally locked — delete + recreate
to swap recorder)
- README rewritten: project renamed to dragonflight, full feature
catalog (ingest, growing-files, scheduler, library + comments,
jobs, settings, cluster), accurate ports, refreshed architecture
diagram, ops scripts inventory
2026-05-23 00:26:03 -04:00
|
|
|
|
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>
|
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-26 22:06:14 -04:00
|
|
|
|
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
|
polish: schedule edit + README refresh
- Schedule: pending rows get an 'Edit' button next to Cancel/Delete;
opens a modal that PUTs /schedules/:id with new name/times/recurrence
(recorder reassignment is intentionally locked — delete + recreate
to swap recorder)
- README rewritten: project renamed to dragonflight, full feature
catalog (ingest, growing-files, scheduler, library + comments,
jobs, settings, cluster), accurate ports, refreshed architecture
diagram, ops scripts inventory
2026-05-23 00:26:03 -04:00
|
|
|
|
</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>
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 15:48:09 -04:00
|
|
|
|
function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, defaultEnd, defaultRecorderId }) {
|
2026-05-23 14:52:04 -04:00
|
|
|
|
// If the user clicked a day on the calendar we honour that; otherwise default
|
|
|
|
|
|
// to "start in 5 minutes, run for 30 min" so the modal is immediately usable.
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
const toLocalInput = (d) => {
|
|
|
|
|
|
const tz = d.getTimezoneOffset() * 60_000;
|
|
|
|
|
|
return new Date(d.getTime() - tz).toISOString().slice(0, 16);
|
|
|
|
|
|
};
|
2026-05-23 14:52:04 -04:00
|
|
|
|
const now = new Date();
|
|
|
|
|
|
now.setSeconds(0, 0);
|
|
|
|
|
|
const startDefault = defaultStart || new Date(now.getTime() + 5 * 60 * 1000);
|
|
|
|
|
|
const endDefault = defaultEnd || new Date(startDefault.getTime() + 30 * 60 * 1000);
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
|
|
|
|
|
|
const [form, setForm] = React.useState({
|
|
|
|
|
|
name: '',
|
2026-05-23 15:48:09 -04:00
|
|
|
|
recorder_id: defaultRecorderId || recorders[0]?.id || '',
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
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');
|
2026-05-23 00:12:42 -04:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
|
|
|
|
|
|
// 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,
|
2026-05-23 00:12:42 -04:00
|
|
|
|
start_at: startD.toISOString(),
|
|
|
|
|
|
end_at: endD.toISOString(),
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
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>
|
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-26 22:06:14 -04:00
|
|
|
|
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
|
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 16:05:41 -04:00
|
|
|
|
Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule, YouTubeImport });
|