// screens-home.jsx // // Two routes share this file: // // • Home - the launcher. Big-button entry into each section of the MAM. // Untouched in this rewrite. // // • Dashboard - the operations view. Rebuilt as a control-room status // board, not a SaaS analytics page. Sections render top-down by // operator priority: // // 1. ON AIR - live recorder tiles, full-width // 2. UP NEXT - single-row strip of next scheduled recordings // 3. ATTENTION - conditional; only when something failed // 4. WORK + CLUSTER - two-column dense panels // 5. STATUS BAR - single mono-text line, bottom // // Anything that would just say "all clear" is hidden, not rendered. function Home({ navigate }) { // Pull live counts so the tile subtitles ("34 assets", "0 live", "3 running") // reflect what's actually in the DB right now, not a stale boot-time cache. const [cards, setCards] = React.useState({}); React.useEffect(() => { let cancelled = false; const load = () => { window.ZAMPP_API.fetch('/metrics/home?hours=1') .then(d => { if (!cancelled) setCards(d?.cards || {}); }) .catch(() => {}); }; load(); const t = setInterval(load, 30_000); return () => { cancelled = true; clearInterval(t); }; }, []); const { ASSETS = [], RECORDERS = [], JOBS = [], NODES = [] } = window.ZAMPP_DATA || {}; const assetsTotal = cards.assets?.total ?? ASSETS.length; const liveCount = cards.recorders?.live ?? RECORDERS.filter(r => r.status === 'recording').length; const totalRecs = cards.recorders?.total ?? RECORDERS.length; const runningJobs = cards.jobs?.running ?? JOBS.filter(j => j.status === 'running' || j.status === 'queued').length; const failedJobs = cards.jobs?.failed_total ?? JOBS.filter(j => j.status === 'failed').length; const nodesOnline = cards.cluster?.online ?? NODES.filter(n => n.status === 'online' || n.online === true).length; const nodesTotal = cards.cluster?.total ?? NODES.length; const tiles = [ { id: 'library', label: 'Library', icon: 'library', tone: 'accent', sub: assetsTotal > 0 ? assetsTotal.toLocaleString() + ' assets' : 'No assets yet', desc: 'Browse projects, bins, and assets. Hover-scrub previews.', }, { id: 'recorders', label: 'Recorders', icon: 'record', tone: 'live', sub: liveCount > 0 ? liveCount + ' live · ' + totalRecs + ' configured' : totalRecs + ' configured', desc: 'SDI · SRT · RTMP ingest. Start, stop, schedule.', }, { id: 'editor', label: 'Editor', icon: 'editor', tone: 'purple', sub: 'Beta', desc: 'Timeline editor with cross-clip preview and render queue.', }, { id: 'jobs', label: 'Jobs', icon: 'jobs', tone: failedJobs > 0 ? 'warn' : 'success', sub: runningJobs > 0 ? runningJobs + ' running' + (failedJobs > 0 ? ' · ' + failedJobs + ' failed' : '') : (failedJobs > 0 ? failedJobs + ' failed' : 'All clear'), desc: 'Proxy + thumbnail queue. Retry failed jobs.', }, { id: 'settings', label: 'Settings', icon: 'settings', tone: 'neutral', sub: 'S3 · Encoder · Growing files', desc: 'Storage, proxy encoder, capture SDK, growing-file mode.', }, ]; const clusterHealthy = !nodesTotal || nodesOnline >= nodesTotal; // Activity strip (#153): live recorders + last-24h assets + alerts. const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4); const recentAssets = (() => { const dayAgo = Date.now() - 86400000; return ASSETS .filter(a => a.created_at && new Date(a.created_at).getTime() > dayAgo) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) .slice(0, 6); })(); const failedCount = JOBS.filter(j => j.status === 'failed').length; const errCount = RECORDERS.filter(r => r.status === 'error').length; const hasActivity = liveRecorders.length || recentAssets.length || failedCount || errCount; return (
Dragonflight

DRAGONFLIGHT

Self-hosted broadcast media-asset management

{tiles.map(t => ( ))}
{hasActivity && (
{(failedCount > 0 || errCount > 0) && (
{errCount > 0 && {errCount} recorder{errCount === 1 ? '' : 's'} in error.} {errCount > 0 && failedCount > 0 && ' '} {failedCount > 0 && {failedCount} failed job{failedCount === 1 ? '' : 's'}.}
)} {liveRecorders.length > 0 && (
Recording now {liveRecorders.length} live
{liveRecorders.map(r => ( ))}
)} {recentAssets.length > 0 && (
Last 24 hours {recentAssets.length} new asset{recentAssets.length === 1 ? '' : 's'}
{recentAssets.map(a => ( ))}
)}
)}
{clusterHealthy ? 'cluster healthy' : 'cluster degraded'} · {nodesOnline}/{nodesTotal || 0} node{nodesTotal === 1 ? '' : 's'} {liveCount > 0 && ( {liveCount} recorder{liveCount === 1 ? '' : 's'} live )}
); } // ───────────────────────────────────────────────────────────────────────── // Dashboard - broadcast-ops control board // ───────────────────────────────────────────────────────────────────────── function Dashboard({ navigate }) { const { RECORDERS, JOBS, NODES } = window.ZAMPP_DATA; // Live state - recompute every second so elapsed timers keep ticking. const [tick, setTick] = React.useState(0); React.useEffect(() => { const i = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(i); }, []); // Upcoming schedule for UP NEXT strip. const [upcoming, setUpcoming] = React.useState([]); React.useEffect(() => { let cancelled = false; const load = () => { window.ZAMPP_API.fetch('/schedules?status=upcoming') .then(d => { if (!cancelled) setUpcoming(d?.schedules || []); }) .catch(() => {}); }; load(); const t = setInterval(load, 30_000); return () => { cancelled = true; clearInterval(t); }; }, []); // Refresh jobs frequently - this screen is the failed-job alert surface. const [jobs, setJobs] = React.useState(JOBS); React.useEffect(() => { let cancelled = false; const load = () => { window.ZAMPP_API.fetch('/jobs') .then(raw => { if (cancelled || !Array.isArray(raw)) return; const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' }; const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', import: 'YouTube' }; const norm = raw.map(j => { const meta = j.metadata || {}; return { ...j, status: statusMap[j.status] || j.status, kind: kindMap[j.type] || j.type || 'Job', asset: j.asset_name || meta.filename || '·', node: meta.node || '·', error: j.error || null, progress: j.progress || 0, }; }); window.ZAMPP_DATA.JOBS = norm; setJobs(norm); }) .catch(() => {}); }; load(); const t = setInterval(load, 5_000); return () => { cancelled = true; clearInterval(t); }; }, []); const liveRecorders = RECORDERS.filter(r => r.status === 'recording'); const erroredRecorders = RECORDERS.filter(r => r.status === 'error'); const runningJobs = jobs.filter(j => j.status === 'running'); const queuedJobs = jobs.filter(j => j.status === 'queued'); const failedJobs = jobs.filter(j => j.status === 'failed'); const doneJobs = jobs.filter(j => j.status === 'done'); const offlineNodes = NODES.filter(n => !(n.status === 'online' || n.online === true)); const onlineNodes = NODES.length - offlineNodes.length; // Next 3 upcoming, sorted, future only const nowMs = Date.now(); const nextUp = upcoming .filter(s => new Date(s.start_at).getTime() > nowMs && s.status === 'pending') .slice(0, 3); const hasAttention = failedJobs.length > 0 || offlineNodes.length > 0 || erroredRecorders.length > 0; return (

Dashboard

Live operations: on-air recorders, jobs, cluster health
{hasAttention && ( {failedJobs.length + offlineNodes.length + erroredRecorders.length} alert{failedJobs.length + offlineNodes.length + erroredRecorders.length === 1 ? '' : 's'} )} {onlineNodes}/{NODES.length || 0} nodes online
{/* ────────── ON AIR ────────── */}
navigate('recorders')} moreLabel="All recorders" /> {liveRecorders.length === 0 ? ( navigate('recorders')} /> ) : (
{liveRecorders.slice(0, 4).map(r => ( navigate('recorders')} /> ))}
)}
{/* ────────── UP NEXT ────────── */} {nextUp.length > 0 && (
navigate('schedule')} moreLabel="Schedule" />
{nextUp.map(s => ( ))}
)} {/* ────────── ATTENTION ────────── */} {hasAttention && (
{erroredRecorders.map(r => ( navigate('recorders')} /> ))} {offlineNodes.map(n => ( navigate('cluster')} /> ))} {failedJobs.slice(0, 5).map(j => ( navigate('jobs')} /> ))} {failedJobs.length > 5 && (
navigate('jobs')}> +{failedJobs.length - 5} more failed jobs
)}
)} {/* ────────── WORK + CLUSTER ────────── */}
{/* JOB QUEUE */}
navigate('jobs')} moreLabel="All jobs" />
{jobs.length === 0 ? (
Queue clear · {doneJobs.length} done
) : ( <>
Job Asset Progress
{/* Running jobs first (with bars), then queued, then a few recent done */} {[...runningJobs, ...queuedJobs, ...doneJobs.slice(0, Math.max(0, 6 - runningJobs.length - queuedJobs.length))] .slice(0, 8) .map(j => )} )}
{/* CLUSTER */}
navigate('cluster')} moreLabel="All nodes" />
{NODES.length === 0 ? (
No nodes registered
) : ( <>
Host CPU Mem
{NODES.slice(0, 6).map(n => )} )}
{/* ────────── STATUS BAR (bottom) ────────── */}
0 ? 'live' : 'idle'}> {liveRecorders.length} live · 0 ? 'accent' : 'idle'}> {runningJobs.length} running · {queuedJobs.length} queued · 0 ? 'warning' : 'idle'}> {failedJobs.length} failed {onlineNodes}/{NODES.length} nodes online · {new Date(nowMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
); } // ───────────────────────────────────────────────────────────────────────── // Subcomponents // ───────────────────────────────────────────────────────────────────────── function DashSectionHead({ title, accent, count, countLabel, onMore, moreLabel }) { return (
{title} {typeof count === 'number' && ( {count} {countLabel && {countLabel}} )} {onMore && ( )}
); } function DashInlineEmpty({ icon, text, cta, onCta }) { return (
{text} {cta && onCta && ( )}
); } function OnAirTile({ recorder, onClick }) { return (
{recorder.live_asset_id ? : } REC {recorder.elapsed || '00:00:00'}
{recorder.name}
{recorder.source || '·'} {recorder.res && recorder.res !== '·' && ( <> · {recorder.res} )} {recorder.codec && recorder.codec !== '·' && ( <> · {recorder.codec} )}
); } function UpNextCard({ schedule }) { const start = new Date(schedule.start_at); const end = new Date(schedule.end_at); const nowMs = Date.now(); const inMs = start.getTime() - nowMs; const inMin = Math.round(inMs / 60_000); const durMin = Math.round((end - start) / 60_000); let relative; if (inMin < 1) relative = 'starting now'; else if (inMin < 60) relative = 'in ' + inMin + 'm'; else if (inMin < 1440) relative = 'in ' + Math.round(inMin / 60) + 'h'; else relative = 'in ' + Math.round(inMin / 1440) + 'd'; const imminent = inMin <= 5; return (
{start.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} {relative}
{schedule.name}
{schedule.recorder_name || 'unbound'} · {durMin}m
); } function AttentionRow({ level, icon, title, detail, onClick }) { return (
{title} {detail}
); } function DashJobRow({ job }) { const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers', YouTube: 'download' }; return (
{job.kind} {job.asset} {job.status === 'running' && ( <> {Math.round(job.progress || 0)}% )} {job.status === 'queued' && queued} {job.status === 'done' && done} {job.status === 'failed' && failed}
); } function DashClusterRow({ node }) { const nodeId = node.hostname || node.id || node.name || 'node'; const isOnline = node.status === 'online' || node.online === true; const cpuPct = node.cpu_percent ?? node.cpu ?? node.cpu_usage ?? null; const memUsed = node.memory_used_gb ?? node.mem ?? (node.mem_used_mb != null ? node.mem_used_mb / 1024 : null); const memTotal = node.memory_total_gb ?? node.mem_total_gb ?? null; const memPct = memUsed != null && memTotal != null ? Math.round((memUsed / memTotal) * 100) : (memUsed != null ? Math.min(100, Math.round((memUsed / 32) * 100)) : null); return (
{nodeId} {node.role && {node.role}} {cpuPct != null ? ( <> 85 ? 'var(--warning)' : cpuPct > 60 ? 'var(--accent)' : 'var(--success)', }} /> {Math.round(cpuPct)}% ) : ·} {memPct != null ? ( <> 85 ? 'var(--warning)' : 'var(--text-2)', }} /> {memUsed < 1 ? Math.round(memUsed * 1024) + 'M' : memUsed.toFixed(1) + 'G'} ) : ·}
); } window.Home = Home; window.Dashboard = Dashboard;