diff --git a/services/web-ui/public/screens-home.jsx b/services/web-ui/public/screens-home.jsx
index 187a47d..d5b56b6 100644
--- a/services/web-ui/public/screens-home.jsx
+++ b/services/web-ui/public/screens-home.jsx
@@ -3,12 +3,19 @@
// 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.
+// Untouched in this rewrite.
//
-// • 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`.
+// • 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")
@@ -158,21 +165,27 @@ function Home({ navigate }) {
);
}
+// ─────────────────────────────────────────────────────────────────────────
+// Dashboard — broadcast-ops control board
+// ─────────────────────────────────────────────────────────────────────────
+
function Dashboard({ navigate }) {
- const { RECORDERS, JOBS, ASSETS, NODES } = window.ZAMPP_DATA;
+ const { RECORDERS, JOBS, 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);
+ // 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);
+ }, []);
- // Real historic sparklines from /metrics/home — buckets the last 24h.
- const [metrics, setMetrics] = React.useState(null);
+ // Upcoming schedule for UP NEXT strip.
+ const [upcoming, setUpcoming] = React.useState([]);
React.useEffect(() => {
let cancelled = false;
const load = () => {
- window.ZAMPP_API.fetch('/metrics/home?hours=24')
- .then(d => { if (!cancelled) setMetrics(d); })
+ window.ZAMPP_API.fetch('/schedules?status=upcoming')
+ .then(d => { if (!cancelled) setUpcoming(d?.schedules || []); })
.catch(() => {});
};
load();
@@ -180,236 +193,451 @@ function Dashboard({ navigate }) {
return () => { cancelled = true; clearInterval(t); };
}, []);
- const cards = metrics?.cards || {};
- const vals = (s) => Array.isArray(s) ? s.map(p => p.v) : [];
+ // 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); };
+ }, []);
- // 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;
+ 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;
- // 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);
+ // 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 (
-
-
-
navigate('library')} style={{ cursor: 'pointer' }}>
-
- Library
-
-
-
{assetsTotal.toLocaleString()}
-
- {sumWindow(cards.assets?.series) > 0
- ? '+' + sumWindow(cards.assets?.series) + ' in 24h'
- : 'total assets'}
-
-
-
-
navigate('recorders')} style={{ cursor: 'pointer' }}>
-
- Live feeds
-
-
-
{liveCount}
-
{totalRecs} configured
-
-
-
navigate('jobs')} style={{ cursor: 'pointer' }}>
-
- Jobs
-
-
-
{runningCount} / {doneCount}
-
0 ? 'var(--warning)' : '' }}>
- {failedCount > 0 ? failedCount + ' failed' : 'All clear'}
- {sumWindow(cards.jobs?.series_done) > 0 && (
- <> · {sumWindow(cards.jobs?.series_done)} in 24h>
- )}
-
-
-
-
navigate('cluster')} style={{ cursor: 'pointer' }}>
-
- Cluster
-
-
-
{nodesOnline} / {nodesTotal}
-
nodes online
-
-
-
-
-
-
- {liveRecorders.length > 0 && (
- <>
-
navigate('recorders')} moreLabel="All recorders" />
-
- {liveRecorders.map(r => (
-
navigate('recorders')}>
-
REC
- {r.live_asset_id
- ?
- :
}
-
- {r.name}
- {r.source && {r.source}}
- {r.elapsed}
-
- {r.project && (
-
-
- {r.project}
-
- )}
-
- ))}
-
-
- >
- )}
-
- navigate('library')} moreLabel="All assets" />
-
- {recentAssets.length === 0 ? (
-
No assets yet.
- ) : recentAssets.map(a => (
-
-
-
-
-
- {a.name}
- {a.project && <> in {a.project}>}
-
-
- {a.duration && {a.duration}}
- {a.res && {a.res}}
-
-
{a.updated}
-
+
+ {/* ────────── ON AIR ────────── */}
+
+ navigate('recorders')}
+ moreLabel="All recorders"
+ />
+ {liveRecorders.length === 0 ? (
+ navigate('recorders')}
+ />
+ ) : (
+
+ {liveRecorders.slice(0, 4).map(r => (
+ navigate('recorders')} />
))}
-
+ )}
+
-
-
navigate('jobs')} moreLabel="View all" />
-
- {JOBS.length === 0
- ?
No jobs.
- : JOBS.slice(0, 5).map(j =>
)}
+ {/* ────────── UP NEXT ────────── */}
+ {nextUp.length > 0 && (
+
+ navigate('schedule')}
+ moreLabel="Schedule"
+ />
+
+ {nextUp.map(s => (
+
+ ))}
+
+ )}
-
-
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;
- const memTotal = n.memory_total_gb ?? n.mem_total_gb ?? null;
- const memPct = memGb && memTotal ? Math.round((memGb / memTotal) * 100) : null;
- return (
-
-
- {nodeId}
-
- {cpuPct != null && (
-
- CPU
-
- 80 ? 'var(--warning)' : 'var(--text-3)' }} />
-
- {Math.round(cpuPct)}%
-
- )}
- {memGb != null && (
-
- MEM
-
- 85 ? 'var(--warning)' : 'var(--text-3)' }} />
-
- {memGb < 1 ? Math.round(memGb * 1024) + 'MB' : memGb.toFixed(1) + 'GB'}
-
- )}
-
- );
- })}
+ {/* ────────── 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) ────────── */}
+
);
}
-function DashSparkline({ data, color }) {
- if (!data || data.length < 2) return ;
- const max = Math.max(...data, 1);
- const min = Math.min(...data, 0);
- const range = max - min || 1;
- const h = 24;
- const w = 80;
- const step = w / (data.length - 1);
- const pts = data.map((d, i) => (i * step) + ',' + (h - ((d - min) / range) * h)).join(' ');
- const area = '0,' + h + ' ' + pts + ' ' + w + ',' + h;
- return (
-
- );
-}
+// ─────────────────────────────────────────────────────────────────────────
+// Subcomponents
+// ─────────────────────────────────────────────────────────────────────────
-function SectionHead({ title, onMore, moreLabel = 'View all' }) {
+function DashSectionHead({ title, accent, count, countLabel, onMore, moreLabel }) {
return (
-
-
{title}
+
+ {title}
+ {typeof count === 'number' && (
+
+ {count}
+ {countLabel && {countLabel}}
+
+ )}
{onMore && (
-
);
}
-function MiniJobRow({ job }) {
+function DashInlineEmpty({ icon, text, cta, onCta }) {
return (
-
-
-
-
-
{job.kind}
-
·
-
{job.asset}
- {job.node &&
on {job.node}}
+
+
+ {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' && (
-
-
-
{job.progress || 0}%
-
+ <>
+
+
+
+ {Math.round(job.progress || 0)}%
+ >
)}
- {job.status === 'failed' && {job.error}
}
-
-
- {job.status === 'running' && job.eta ? job.eta : {job.status}}
-
+ {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'}
+ >
+ ) : —}
+
);
}