// 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
Self-hosted broadcast media-asset management
{tiles.map(t => (
navigate(t.id)}
>
{t.label}
{t.sub}
{t.desc}
))}
navigate('dashboard')}
>
Dashboard
Operations view
Recent activity, job queue, cluster health.
{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'}. }
navigate('dashboard')}>Open Dashboard
)}
{liveRecorders.length > 0 && (
Recording now
{liveRecorders.length} live
navigate('monitors')}>Monitors
{liveRecorders.map(r => (
navigate('recorders')}>
REC
{r.name}
{r.source_type || 'sdi'}
))}
)}
{recentAssets.length > 0 && (
Last 24 hours
{recentAssets.length} new asset{recentAssets.length === 1 ? '' : 's'}
navigate('library')}>Library
{recentAssets.map(a => (
navigate('library')}>
{a.display_name || a.filename || 'untitled'}
{(() => {
const mins = Math.round((Date.now() - new Date(a.created_at)) / 60000);
if (mins < 60) return mins + 'm';
const h = Math.round(mins / 60);
return h + 'h';
})()}
))}
)}
)}
{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 && (
{moreLabel || 'View all'}
)}
);
}
function DashInlineEmpty({ icon, text, cta, onCta }) {
return (
{text}
{cta && onCta && (
{cta}
)}
);
}
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;