// 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
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;