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) ────────── */} +
+ 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' })} +
); } -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 && ( + + )} +
+ ); +} + +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'} + + ) : } +
); }