// screens-home.jsx // // Two routes share this file: // // • Home — the launcher. Big-button entry into each section of the MAM. // This is what you see when you log in / hit /. Resembles the original // first-version landing page. // // • Dashboard — the operations view (recent activity, job queue, cluster, // live recorders). Reachable from the sidebar or from the Home launcher. // This is the React component that used to be `Home`. 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; return (
Dragonflight

DRAGONFLIGHT

Self-hosted broadcast media-asset management

{tiles.map(t => ( ))}
{clusterHealthy ? 'cluster healthy' : 'cluster degraded'} · {nodesOnline}/{nodesTotal || 0} node{nodesTotal === 1 ? '' : 's'} {liveCount > 0 && ( {liveCount} recorder{liveCount === 1 ? '' : 's'} live )}
); } function Dashboard({ navigate }) { const { RECORDERS, JOBS, ASSETS, NODES } = window.ZAMPP_DATA; // Live (current-state) data from the boot-time data load const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4); const runningJobs = JOBS.filter(j => j.status === 'running' || j.status === 'queued'); const recentAssets = [...ASSETS].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).slice(0, 6); // Real historic sparklines from /metrics/home — buckets the last 24h. const [metrics, setMetrics] = React.useState(null); React.useEffect(() => { let cancelled = false; const load = () => { window.ZAMPP_API.fetch('/metrics/home?hours=24') .then(d => { if (!cancelled) setMetrics(d); }) .catch(() => {}); }; load(); const t = setInterval(load, 30_000); return () => { cancelled = true; clearInterval(t); }; }, []); const cards = metrics?.cards || {}; const vals = (s) => Array.isArray(s) ? s.map(p => p.v) : []; // Card values come from /metrics so that "Library" reflects what's in the // DB right now, not whatever ZAMPP_DATA happens to be cached as on first load. const assetsTotal = cards.assets?.total ?? ASSETS.length; const liveCount = cards.recorders?.live ?? liveRecorders.length; const totalRecs = cards.recorders?.total ?? RECORDERS.length; const runningCount = cards.jobs?.running ?? runningJobs.length; const doneCount = cards.jobs?.done_total ?? JOBS.filter(j => j.status === 'done').length; const failedCount = 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; // Sum the most recent hour of each bucketed series for the delta line so // the "+N this hour" hint always reflects the latest bucket. const sumWindow = (series) => (Array.isArray(series) ? series.reduce((a, p) => a + p.v, 0) : 0); return (

Dashboard

{liveCount > 0 ? liveCount + ' live · ' : ''} {runningCount > 0 ? runningCount + ' job' + (runningCount > 1 ? 's running' : ' running') + ' · ' : ''} {assetsTotal.toLocaleString()} assets

navigate('library')} style={{ cursor: 'pointer' }}>
Library
{assetsTotal.toLocaleString()}
{sumWindow(cards.assets?.series) > 0 ? '+' + sumWindow(cards.assets?.series) + ' added in last 24h' : 'Total assets'}
navigate('recorders')} style={{ cursor: 'pointer' }}>
Live feeds
{liveCount}
{totalRecs} recorder{totalRecs === 1 ? '' : 's'} configured
navigate('jobs')} style={{ cursor: 'pointer' }}>
Jobs
{runningCount} / {doneCount} done
0 ? 'var(--warning)' : '' }}> {failedCount > 0 ? failedCount + ' failed' : 'All clear'} {sumWindow(cards.jobs?.series_done) > 0 && ( <> · {sumWindow(cards.jobs?.series_done)} completed in last 24h )}
navigate('cluster')} style={{ cursor: 'pointer' }}>
Cluster nodes
{nodesOnline} / {nodesTotal} online
Heartbeat within 2 min
{liveRecorders.length > 0 && ( <> navigate('recorders')} moreLabel="All recorders" />
{liveRecorders.map(r => (
navigate('recorders')}>
REC
{r.live_asset_id ? : }
{r.name} {r.elapsed}
))}
)} navigate('library')} moreLabel="All assets" />
{recentAssets.length === 0 ? (
No assets yet.
) : recentAssets.map(a => (
{a.name} {a.project && <> in {a.project}}
{a.updated}
))}
navigate('jobs')} moreLabel="View all" />
{JOBS.length === 0 ?
No jobs.
: JOBS.slice(0, 5).map(j => )}
navigate('cluster')} moreLabel="View all" />
{NODES.length === 0 ?
No nodes found.
: NODES.slice(0, 4).map(n => { const nodeId = n.id || n.hostname || n.name || 'node'; const isOnline = n.status === 'online' || n.online === true; const cpuPct = n.cpu_percent ?? n.cpu ?? n.cpu_usage ?? null; const memRaw = n.memory_used_gb ?? n.mem ?? (n.mem_used_mb != null ? n.mem_used_mb / 1024 : null); const memGb = memRaw != null ? Number(memRaw) : null; return (
{nodeId} {cpuPct != null && CPU {Math.round(cpuPct)}%} {memGb != null && {memGb < 1 ? Math.round(memGb * 1024) + 'MB' : memGb.toFixed(1) + 'GB'} RAM}
); })}
); } function SectionHead({ title, onMore, moreLabel = 'View all' }) { return (
{title}
{onMore && ( )}
); } function MiniJobRow({ job }) { return (
{job.kind} · {job.asset}
{job.status === 'running' && (
)} {job.status === 'failed' &&
{job.error}
}
{job.status === 'running' ? job.eta : job.status}
); } window.Home = Home; window.Dashboard = Dashboard;