From f21bc490e86c974dbcb63b835a557ac49380753e Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 17:15:32 -0400 Subject: [PATCH] feat(web-ui): redesigned Dashboard + playout as-run log Dashboard (screens-home.jsx): rebuild to new design, fully live-wired. Dropped fabricated figures per "real data" rule (object-store %, uptime, storage breakdown); repurposed ingest cell to real Assets-24h count. Fixed undefined refs and double-rendered Resources section. Playout: as-run writer in scheduler.js writeAsRun() off the health-tick /status poll; AsRunPanel UI + missing CSS in styles-playout.css. Co-Authored-By: Claude Opus 4.8 --- services/mam-api/src/scheduler.js | 70 ++ services/web-ui/public/screens-home.jsx | 785 ++++++++++----------- services/web-ui/public/screens-playout.jsx | 54 ++ services/web-ui/public/styles-playout.css | 24 + services/web-ui/public/styles-screens.css | 228 ++++++ 5 files changed, 767 insertions(+), 394 deletions(-) diff --git a/services/mam-api/src/scheduler.js b/services/mam-api/src/scheduler.js index 51436b4..e78fe41 100644 --- a/services/mam-api/src/scheduler.js +++ b/services/mam-api/src/scheduler.js @@ -218,6 +218,65 @@ async function enqueueNextOccurrence(schedule, client) { // sidecars; multi-replica pings would just waste cycles. A missed probe is // counted via last_heartbeat_at age: > 3 * TICK_INTERVAL means 3 consecutive // misses. +// Persist the as-run compliance log for one channel from a sidecar /status +// payload. The sidecar reports the currently on-air item via currentItemId / +// currentClip / currentItemStartedAt (playout-manager.getStatus). We keep at +// most one "open" row (ended_at IS NULL) per channel: when the on-air item +// changes (or playout stops) we close the open row — stamping ended_at and a +// computed duration_s — and, if a new clip is on air, open a fresh row. +// +// playout_as_run columns (migration 029): id, channel_id, asset_id, item_id, +// clip_name, started_at, ended_at, duration_s, result. +async function writeAsRun(client, channelId, engine) { + const currentItemId = engine && engine.currentItemId ? engine.currentItemId : null; + + // The currently-open as-run row for this channel, if any. + const { rows: openRows } = await client.query( + `SELECT id, item_id, started_at FROM playout_as_run + WHERE channel_id = $1 AND ended_at IS NULL + ORDER BY started_at DESC LIMIT 1`, + [channelId] + ); + const open = openRows[0] || null; + + // Same clip still on air → nothing to do. + if (open && currentItemId && open.item_id === currentItemId) return; + // Nothing on air and nothing open → nothing to do. + if (!open && !currentItemId) return; + + // Close the previous open row (clip changed, or playout stopped). + if (open) { + await client.query( + `UPDATE playout_as_run + SET ended_at = NOW(), + duration_s = EXTRACT(EPOCH FROM (NOW() - started_at)) + WHERE id = $1`, + [open.id] + ); + } + + // Open a new row for the clip now on air. Resolve the item's asset_id so the + // compliance log links back to the source asset even after the playlist item + // is later deleted. + if (currentItemId) { + let assetId = null; + try { + const { rows } = await client.query( + 'SELECT asset_id FROM playout_items WHERE id = $1', [currentItemId] + ); + if (rows.length > 0) assetId = rows[0].asset_id; + } catch (_) { /* item may have been deleted; log without asset link */ } + + await client.query( + `INSERT INTO playout_as_run + (channel_id, asset_id, item_id, clip_name, started_at, result) + VALUES ($1, $2, $3, $4, COALESCE($5::timestamptz, NOW()), 'played')`, + [channelId, assetId, currentItemId, engine.currentClip || null, + engine.currentItemStartedAt || null] + ); + } +} + async function playoutHealthTick(client) { let channels; try { @@ -243,6 +302,17 @@ async function playoutHealthTick(client) { await client.query( 'UPDATE playout_channels SET last_heartbeat_at = NOW() WHERE id = $1', [ch.id] ); + // As-run compliance log: the sidecar only tracks the on-air clip locally + // (playout-manager._reportAsRunStart). On every successful status poll we + // detect a clip change here and persist it to playout_as_run — close the + // previous open row and open a new one. Failures are swallowed so a logging + // hiccup never knocks a healthy channel into failover. + try { + const engine = await r.json().catch(() => null); + if (engine) await writeAsRun(client, ch.id, engine); + } catch (e) { + console.warn(`[scheduler] as-run write failed for ${ch.id}: ${e.message}`); + } } catch (err) { // When last_heartbeat_at is NULL (channel just spawned), fall back to // updated_at (set to NOW() by spawnChannelSidecar). This prevents a diff --git a/services/web-ui/public/screens-home.jsx b/services/web-ui/public/screens-home.jsx index 2f1f6b9..6a79944 100644 --- a/services/web-ui/public/screens-home.jsx +++ b/services/web-ui/public/screens-home.jsx @@ -349,20 +349,51 @@ function DownloadsModal({ onClose }) { } // ───────────────────────────────────────────────────────────────────────── -// Dashboard - broadcast-ops control board +// Dashboard - broadcast-ops control board (design rebuild) +// +// Layout follows the zampp design reference: an .ops-header with a live clock, +// a single .ops-stats status strip, a two-column .dash-grid (On air + Job +// queue on the left; Needs attention + Cluster on the right), and a mono +// .dash-statusbar footer. +// +// CRITICAL — "real data, drop the rest": every panel is wired to a live API. +// The design shipped several demo figures the platform has no endpoint for — +// Ingest GB/day, Object-store %, Uptime, and a storage-by-type breakdown bar. +// Those are DROPPED rather than faked: +// • "Ingest · today" GB → repurposed to "Assets · 24h" (real count summed +// from /metrics/home cards.assets.series). +// • "Object store %" → no disk/capacity API → stat cell omitted. +// • "Uptime" → no uptime field on cluster nodes → stat cell omitted. +// • StorageBar panel → no storage-by-type API → side panel omitted. +// The strip therefore renders the four cells we can populate honestly +// (On air, Assets · 24h, Jobs, Cluster). // ───────────────────────────────────────────────────────────────────────── +function hms(t) { + const p = String(t).split(':').map(Number); + if (p.length !== 3 || p.some(isNaN)) return 0; + return p[0] * 3600 + p[1] * 60 + p[2]; +} + 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); + // Home metrics — gives us the real cluster snapshot and the assets-per-bucket + // series we sum into the honest "Assets · 24h" stat (no GB-ingest API exists). + const [home, setHome] = React.useState(null); React.useEffect(() => { - const i = setInterval(() => setTick(t => t + 1), 1000); - return () => clearInterval(i); + let cancelled = false; + const load = () => { + window.ZAMPP_API.fetch('/metrics/home?hours=24') + .then(d => { if (!cancelled) setHome(d || null); }) + .catch(() => {}); + }; + load(); + const t = setInterval(load, 30_000); + return () => { cancelled = true; clearInterval(t); }; }, []); - // Upcoming schedule for UP NEXT strip. + // Upcoming schedule — surfaces armed/standby sources in the on-air empty state. const [upcoming, setUpcoming] = React.useState([]); React.useEffect(() => { let cancelled = false; @@ -408,447 +439,413 @@ function Dashboard({ navigate }) { return () => { cancelled = true; clearInterval(t); }; }, []); - const liveRecorders = RECORDERS.filter(r => r.status === 'recording'); + const liveRecorders = RECORDERS.filter(r => r.status === 'recording'); + const armedRecorders = RECORDERS.filter(r => r.status === 'armed'); + const idleRecorders = RECORDERS.filter(r => r.status === 'idle' || r.status === 'stopped' || r.status === 'ready'); 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); + // Cluster — prefer the live /metrics/home snapshot, fall back to bootstrapped NODES. + const homeNodes = home?.cards?.cluster?.nodes || null; + const clusterNodes = homeNodes && homeNodes.length ? homeNodes : NODES; + const offlineNodes = clusterNodes.filter(n => !(n.status === 'online' || n.online === true)); + const nodesTotal = clusterNodes.length; + const onlineNodes = nodesTotal - offlineNodes.length; - const hasAttention = failedJobs.length > 0 || offlineNodes.length > 0 || erroredRecorders.length > 0; + // Real "Assets · 24h" figure: sum the assets-created buckets from /metrics/home. + // null until the first poll resolves so we can hide the cell rather than show 0. + const assets24h = (() => { + const series = home?.cards?.assets?.series; + if (!Array.isArray(series)) return null; + return series.reduce((sum, p) => sum + (p.v || 0), 0); + })(); + + // Sources offered in the on-air empty state (armed first, then idle). + const standbySources = [...armedRecorders, ...idleRecorders]; + + // Job-queue table order: running (with bars) first, then failed, queued, and a + // few recent done to fill — capped so the table stays a glanceable summary. + const orderedJobs = [ + ...runningJobs, + ...failedJobs, + ...queuedJobs, + ...doneJobs, + ].slice(0, 7); + + const attentionCount = erroredRecorders.length + failedJobs.length + offlineNodes.length; + + // Needs-attention list, danger-first. + const alerts = [ + ...erroredRecorders.map(r => ({ + key: 'r-' + r.id, sev: 'danger', + title: r.name + ' — recorder error', + meta: r.error_message || r.url || 'signal lost', + action: 'Reconnect', to: 'recorders', + })), + ...failedJobs.map(j => ({ + key: 'j-' + j.id, sev: 'danger', + title: j.kind + ' failed · ' + (j.asset || '·'), + meta: j.error ? j.error.slice(0, 100) : 'job failed', + action: 'Retry', to: 'jobs', + })), + ...offlineNodes.map(n => ({ + key: 'n-' + (n.id || n.hostname || n.name), sev: 'warning', + title: 'Node ' + (n.hostname || n.id || n.name) + ' offline', + meta: (n.role ? n.role + ' · ' : '') + (n.ip || 'no heartbeat for >2 min'), + action: 'Inspect', to: 'cluster', + })), + ].slice(0, 8); 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 - +
+
+
+

Dashboard

+
Live operations · on-air recorders, jobs, and cluster health
+
+
+
+ + {onlineNodes}/{nodesTotal} nodes online +
+ +
- {/* ────────── ON AIR ────────── */} -
- navigate('recorders')} - moreLabel="All recorders" + {/* Status strip — only cells backed by a real endpoint. */} +
+ + {armedRecorders.length} armed + {idleRecorders.length} idle + + } /> + {(home?.cards?.assets?.total ?? '—')} total in library} /> - {liveRecorders.length === 0 ? ( - navigate('recorders')} - /> - ) : ( -
- {liveRecorders.slice(0, 4).map(r => ( - navigate('recorders')} /> - ))} -
- )} -
+ + {queuedJobs.length} queued · {doneJobs.length} done + {failedJobs.length ? · {failedJobs.length} failed : null} + + } /> + {offlineNodes[0].hostname || offlineNodes[0].id} offline + : {nodesTotal ? 'all healthy' : 'no nodes registered'} + } /> +
- {/* ────────── UP NEXT ────────── */} - {nextUp.length > 0 && ( -
- navigate('schedule')} - moreLabel="Schedule" +
+ {/* ───── MAIN: On air + Job queue ───── */} +
+ navigate('recorders')} + moreLabel="All recorders" + live={liveRecorders.length > 0} /> -
- {nextUp.map(s => ( - - ))} -
-
- )} + {liveRecorders.length > 0 ? ( +
+ {[...liveRecorders, ...armedRecorders].slice(0, 6).map((r, i) => ( + navigate('recorders')} /> + ))} +
+ ) : ( + navigate('recorders')} /> + )} - {/* ────────── 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 + {orderedJobs.length > 0 ? ( + + ) : ( +
+
+ +
+
Queue clear
+
{doneJobs.length} job{doneJobs.length === 1 ? '' : 's'} completed.
- {/* 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 */} -
- + {alerts.length > 0 && ( + + +
+ {alerts.map(a => )} +
+
+ )} + + navigate('cluster')} moreLabel="All nodes" /> -
- {NODES.length === 0 ? ( -
- - No nodes registered -
- ) : ( - <> -
- - Host - CPU - Mem + {nodesTotal > 0 ? ( +
+ {clusterNodes.slice(0, 8).map(n => )} +
+ ) : ( +
+
+ +
+
No nodes registered
+
Cluster agents have not reported in.
- {NODES.slice(0, 6).map(n => )} - - )} -
+
+
+ )} + + {/* Resources panel (live cluster GPU/CPU detail), rendered once. */} + {window.ClusterResources && ( + + + + + )}
-
+
- {/* ────────── RESOURCES ────────── */} -
- - {window.ClusterResources && } -
- - {/* ────────── RESOURCES ────────── */} -
- - {window.ClusterResources && React.createElement(window.ClusterResources)} -
- - {/* ────────── 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' })} -
+
+ {liveRecorders.length} live + {runningJobs.length} running + {queuedJobs.length} queued + {failedJobs.length} failed + + {onlineNodes}/{nodesTotal} nodes online + · + +
); } // ───────────────────────────────────────────────────────────────────────── -// Subcomponents +// Subcomponents — ported from the design, wired to live recorder/job/node data // ───────────────────────────────────────────────────────────────────────── -function DashSectionHead({ title, accent, count, countLabel, onMore, moreLabel }) { +function useNow() { + const [now, setNow] = React.useState(new Date()); + React.useEffect(() => { + const i = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(i); + }, []); + return now; +} + +function Clock() { + const t = useNow(); + const days = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; + const pad = n => String(n).padStart(2, '0'); return ( -
- {title} - {typeof count === 'number' && ( - - {count} - {countLabel && {countLabel}} - - )} +
+ + {pad(t.getHours())}:{pad(t.getMinutes())}:{pad(t.getSeconds())} + {days[t.getDay()]} +
+ ); +} + +function ClockTime() { + const t = useNow(); + const pad = n => String(n).padStart(2, '0'); + return {pad(t.getHours())}:{pad(t.getMinutes())}:{pad(t.getSeconds())}; +} + +function StatCell({ label, value, unit, foot }) { + return ( +
+
{label}
+
{value}{unit ? {unit} : null}
+
{foot}
+
+ ); +} + +function SectionHead({ title, sub, count, onMore, moreLabel = 'View all', live }) { + return ( +
+ {live && } + {title} + {count != null && {count}} + {sub && {sub}} {onMore && ( - + )}
); } -function DashInlineEmpty({ icon, text, cta, onCta }) { +function IngestTile({ r, seed, onClick }) { + const rec = r.status === 'recording'; + const isAudio = r.audio || r.media_type === 'audio'; + const elapsed = r.elapsed && hms(r.elapsed) > 0 ? hms(r.elapsed) : 0; return ( -
- - {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' && ( - <> - - - - {Math.round(job.progress || 0)}% - +
+
+ {r.live_asset_id && window.HlsPreview ? ( + + ) : isAudio ? ( +
+ ) : ( +
)} - {job.status === 'queued' && queued} - {job.status === 'done' && done} - {job.status === 'failed' && failed} - + {!rec &&
} +
+ {rec ? REC : ARMED} + {(r.source || r.source_type) && {r.source || r.source_type}} +
+
+ {r.name} + {rec && } +
+
+
+ {rec ? (r.bitrate || 'recording') : 'standby'} + {(r.res && r.res !== '·') || r.codec ? · : null} + {r.res && r.res !== '·' && r.res !== '—' ? r.res : (r.codec || '')} + {r.node && r.node !== '·' && {r.node}} +
); } -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 +function OnAirEmpty({ sources, onStart }) { + return ( +
+
+ +
+
Nothing on air
+
All recorders are idle — start a source to begin capturing.
+
+ +
+ {sources.length > 0 && ( +
+ {sources.slice(0, 4).map(s => ( + + ))} +
+ )} +
+ ); +} + +function JobQueueTable({ jobs }) { + return ( +
+
+ JobAssetNodeProgressETA +
+ {jobs.map(j => ( +
+ + + {j.kind} + + {j.asset} + {j.node} + + {j.status === 'running' + ? + : } + + {j.status === 'running' ? (j.eta || '—') : '—'} +
+ ))} +
+ ); +} + +function JobChip({ status, error }) { + const map = { done: ['success', 'Done'], queued: ['neutral', 'Queued'], failed: ['danger', 'Failed'] }; + const [cls, label] = map[status] || ['neutral', status]; + return {label}; +} + +function AttentionRow({ a, navigate }) { + return ( +
+ +
+
{a.title}
+
{a.meta}
+
+ +
+ ); +} + +function NodeRow({ n }) { + const off = !(n.status === 'online' || n.online === true); + const nodeId = n.hostname || n.id || n.name || 'node'; + const cpuPct = n.cpu_percent ?? n.cpu ?? n.cpu_usage ?? null; + const memUsed = n.memory_used_gb ?? n.mem ?? (n.mem_used_mb != null ? n.mem_used_mb / 1024 : null); + const memTotal = n.memory_total_gb ?? n.memTotal ?? n.mem_total_gb ?? null; + const memPct = (memUsed != null && memTotal) ? Math.round((memUsed / memTotal) * 100) : (memUsed != null ? Math.min(100, Math.round((memUsed / 32) * 100)) : null); + const gpus = n.gpus || n.devices || []; + const gpuCount = Array.isArray(gpus) ? gpus.length : 0; 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'} - - ) : ·} - +
+
+ + {nodeId} + {n.role && {n.role}} +
+ {off ? ( +
offline
+ ) : ( +
+ {cpuPct != null + ? + :
CPU·
} + {memPct != null + ? + :
RAM·
} +
+ {gpuCount ? gpuCount + '×GPU' : '—'} +
+
+ )} +
+ ); +} + +function NodeMetric({ label, pct, text }) { + const color = pct > 85 ? 'var(--danger)' : pct > 60 ? 'var(--warning)' : 'var(--accent)'; + return ( +
+ {label} + + {text}
); } diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx index 3d1d2b8..d0dd9d6 100644 --- a/services/web-ui/public/screens-playout.jsx +++ b/services/web-ui/public/screens-playout.jsx @@ -445,6 +445,58 @@ function ProgramMonitor({ channel, engine }) { } // ── Channel detail (monitors + bin + playlist + transport) ─────────────────── +// As-run compliance log. Polls the existing GET /channels/:id/asrun endpoint +// (rows written by the scheduler health tick on every clip change) and shows the +// most recent plays: start time, clip, on-air duration, result. +function AsRunPanel({ channel, refreshKey }) { + const [rows, setRows] = React.useState([]); + + React.useEffect(() => { + let alive = true; + let t; + const poll = async () => { + try { + const r = await poFetch('/channels/' + channel.id + '/asrun'); + if (alive) setRows(Array.isArray(r) ? r : []); + } catch (_) {} + t = setTimeout(poll, 5000); + }; + poll(); + return () => { alive = false; clearTimeout(t); }; + }, [channel.id, refreshKey]); + + const fmtTime = (ts) => { + if (!ts) return '—'; + const d = new Date(ts); + return isNaN(d) ? '—' : d.toLocaleTimeString(); + }; + + return ( +
+
As-Run Log
+ {rows.length === 0 + ?
No as-run entries yet.
+ : ( + + + + + + {rows.slice(0, 50).map((r) => ( + + + + + + + ))} + +
TimeClipDurationResult
{fmtTime(r.started_at)}{r.clip_name || r.item_id || '—'}{r.duration_s != null ? fmtDuration(Number(r.duration_s)) : (r.ended_at ? '—' : 'on air')}{r.result || 'played'}
+ )} +
+ ); +} + function ChannelDetail({ channel, onChannelChange }) { const [playlists, setPlaylists] = React.useState([]); const [playlistId, setPlaylistId] = React.useState(null); @@ -544,6 +596,8 @@ function ChannelDetail({ channel, onChannelChange }) { onReload={loadItems} /> )} + +
); } diff --git a/services/web-ui/public/styles-playout.css b/services/web-ui/public/styles-playout.css index 1699146..a0fca38 100644 --- a/services/web-ui/public/styles-playout.css +++ b/services/web-ui/public/styles-playout.css @@ -156,6 +156,30 @@ } .po-pl-total { color: var(--text-2); } +/* As-run log */ +.po-asrun { + display: flex; flex-direction: column; gap: 8px; + padding: 12px; background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px; +} +.po-asrun-table { + width: 100%; border-collapse: collapse; font-size: 12px; +} +.po-asrun-table th { + text-align: left; font-weight: 600; color: var(--text-3); + font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; + padding: 4px 8px; border-bottom: 1px solid var(--border); +} +.po-asrun-table td { + padding: 5px 8px; border-bottom: 1px solid var(--border); + color: var(--text-1); overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; max-width: 220px; +} +.po-asrun-table tr:last-child td { border-bottom: none; } +.po-asrun-result { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; } +.po-asrun-played { color: var(--success); } +.po-asrun-skipped { color: var(--warning); } +.po-asrun-error { color: var(--danger); } + /* Downloads modal section header */ .downloads-section-head { display: flex; align-items: center; gap: 6px; diff --git a/services/web-ui/public/styles-screens.css b/services/web-ui/public/styles-screens.css index 2ae2480..9745e73 100644 --- a/services/web-ui/public/styles-screens.css +++ b/services/web-ui/public/styles-screens.css @@ -1376,3 +1376,231 @@ /* Tint Cancel-all-failed button to signal destructive action without making it loud — same pattern as the per-row Cancel. */ .jobs-cancel-all { color: var(--danger); } + +/* ======================================================================== + Dashboard (operations overview) - design rebuild. + Appended last so the design's .dash-grid / .dash-statusbar override the + earlier (pre-redesign) definitions of those two container classes. + ======================================================================== */ + +.page.dashboard { padding: 0; } + +.ops-header { + display: flex; align-items: flex-end; gap: 16px; + padding: 24px 28px 18px; +} +.ops-header h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.02em; } +.ops-sub { margin-top: 5px; color: var(--text-3); font-size: 12.5px; } +.ops-header-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } + +.ops-clock { + display: flex; align-items: center; gap: 9px; + height: 32px; padding: 0 12px; + border: 1px solid var(--border); border-radius: var(--r-sm); + background: var(--bg-1); +} +.ops-clock-dot { + width: 6px; height: 6px; border-radius: 50%; + background: var(--live); box-shadow: 0 0 0 3px var(--live-soft); + animation: dotpulse 1.6s ease-in-out infinite; +} +.ops-clock-time { font-size: 13px; font-weight: 500; letter-spacing: 0.03em; color: var(--text-1); font-variant-numeric: tabular-nums; } +.ops-clock-day { font-size: 10px; color: var(--text-3); letter-spacing: 0.08em; } + +.ops-nodes-pill { + display: flex; align-items: center; gap: 7px; + height: 32px; padding: 0 12px; + border: 1px solid var(--border); border-radius: var(--r-sm); + background: var(--bg-1); + font-size: 12px; color: var(--text-2); font-family: var(--font-mono); +} + +/* ---- status strip ---- */ +.ops-stats { + display: grid; grid-template-columns: repeat(4, 1fr); + margin: 0 28px; + background: var(--bg-1); border: 1px solid var(--border); + border-radius: var(--r-lg); overflow: hidden; +} +.ops-stats.six { grid-template-columns: repeat(6, 1fr); } +.stat-cell { padding: 15px 16px 14px; border-left: 1px solid var(--border); min-width: 0; } +.stat-cell:first-child { border-left: 0; } +.stat-cell-label { + font-size: 10.5px; color: var(--text-3); font-weight: 600; + text-transform: uppercase; letter-spacing: 0.06em; white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; +} +.stat-cell-value { + margin-top: 9px; font-size: 26px; font-weight: 600; line-height: 1; + letter-spacing: -0.02em; font-variant-numeric: tabular-nums; + display: flex; align-items: baseline; gap: 6px; +} +.stat-cell-unit { font-size: 12px; font-weight: 500; color: var(--text-3); letter-spacing: 0; } +.stat-cell-foot { + margin-top: 10px; font-size: 11.5px; color: var(--text-3); + display: flex; align-items: center; gap: 8px; white-space: nowrap; + overflow: hidden; +} +.stat-cell-foot .foot-danger { color: var(--danger); } +.stat-cell-foot .foot-warn { color: var(--warning); } +.stat-pips { display: flex; align-items: center; gap: 11px; } +.stat-pip { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-2); font-variant-numeric: tabular-nums; } +.stat-pip i { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; } +.stat-pip.armed i { background: var(--accent); } +.stat-pip.idle i { background: var(--text-4); } +.stat-pip.zero { color: var(--text-4); } +.stat-pip.zero i { opacity: 0.4; } + +/* ---- section heads ---- */ +.section-head { display: flex; align-items: center; gap: 10px; padding: 22px 0 11px; } +.section-head:first-child { padding-top: 6px; } +.section-head-title { font-size: 13px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; } +.section-head-sub { font-size: 11.5px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.section-head-count { font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; color: var(--danger); background: var(--danger-soft); padding: 1px 7px; border-radius: 99px; } +.section-head .btn { margin-left: auto; flex-shrink: 0; } +.section-head-live { + width: 7px; height: 7px; border-radius: 50%; + background: var(--live); box-shadow: 0 0 0 3px var(--live-soft); + animation: dotpulse 1.6s ease-in-out infinite; flex-shrink: 0; +} + +/* ---- dashboard grid (overrides earlier .dash-grid) ---- */ +.page.dashboard .dash-grid { + display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(300px, 1fr); + gap: 22px; padding: 6px 28px 8px; align-items: start; +} +.dash-main, .dash-side { min-width: 0; } + +/* ---- live ingest tiles ---- */ +.live-now-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(188px, 1fr)); + gap: 12px; +} +.ingest-tile { + background: var(--bg-1); border: 1px solid var(--border); + border-radius: var(--r-md); overflow: hidden; cursor: pointer; + transition: border-color 120ms, transform 120ms; +} +.ingest-tile:hover { border-color: var(--border-strong); transform: translateY(-1px); } +.ingest-tile.recording { border-color: rgba(255,59,48,0.28); } +.ingest-tile-screen { position: relative; aspect-ratio: 16 / 9; background: var(--bg-2); overflow: hidden; } +.ingest-tile-audio { + position: absolute; inset: 0; display: grid; place-items: center; padding: 16px; + background: linear-gradient(160deg, var(--bg-2), var(--bg-1)); +} +.ingest-tile-audio .waveform { width: 100%; height: 58%; opacity: 0.85; } +.ingest-tile-veil { position: absolute; inset: 0; background: rgba(11,13,17,0.5); z-index: 1; } +.ingest-tile-top { position: absolute; top: 8px; left: 8px; right: 8px; display: flex; align-items: center; gap: 6px; z-index: 2; } +.ingest-tile-top .badge.outline { margin-left: auto; background: rgba(0,0,0,0.45); border-color: rgba(255,255,255,0.18); color: #fff; backdrop-filter: blur(4px); } +.ingest-tile-bottom { position: absolute; left: 8px; right: 8px; bottom: 8px; display: flex; align-items: center; gap: 6px; z-index: 2; } +.ingest-tile-name { + color: #fff; font-size: 12px; font-weight: 500; + background: rgba(0,0,0,0.6); padding: 3px 8px; border-radius: 4px; backdrop-filter: blur(4px); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; +} +.ingest-tile-tc { margin-left: auto; color: #fff; font-size: 11px; background: rgba(0,0,0,0.6); padding: 3px 6px; border-radius: 4px; backdrop-filter: blur(4px); flex-shrink: 0; } +.ingest-tile-foot { display: flex; align-items: center; gap: 8px; padding: 8px 11px; font-size: 11px; color: var(--text-3); } +.ingest-tile-foot .dot-sep { color: var(--text-4); } +.ingest-tile-node { margin-left: auto; color: var(--text-4); } + +/* ---- on-air empty / standby ---- */ +.onair-empty { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; } +.onair-empty-head { display: flex; align-items: center; gap: 14px; padding: 18px; } +.onair-empty-icon { + width: 38px; height: 38px; flex-shrink: 0; border-radius: 50%; + background: var(--bg-3); border: 1px solid var(--border); + display: grid; place-items: center; color: var(--text-3); +} +.onair-empty-copy { flex: 1; min-width: 0; } +.onair-empty-title { font-size: 13.5px; font-weight: 600; } +.onair-empty-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; } +.onair-empty-head .btn { flex-shrink: 0; } +.onair-sources { + display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 8px; padding: 14px; border-top: 1px solid var(--border); background: var(--bg-0); +} +.onair-source { + display: flex; align-items: center; gap: 9px; padding: 9px 11px; + background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--r-md); + text-align: left; cursor: pointer; transition: background 80ms, border-color 80ms; +} +.onair-source:hover { background: var(--bg-3); border-color: var(--border-strong); } +.onair-source-name { font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.onair-source-src { font-size: 10px; font-family: var(--font-mono); color: var(--text-3); padding: 1px 6px; border: 1px solid var(--border-strong); border-radius: 4px; } +.onair-source-go { margin-left: auto; display: flex; align-items: center; gap: 3px; font-size: 11px; color: var(--accent-text); white-space: nowrap; } + +/* ---- job queue table ---- */ +.job-table { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; } +.job-table-head, .job-table-row { + display: grid; grid-template-columns: 148px minmax(0, 1fr) 84px 170px 52px; + gap: 14px; align-items: center; padding: 0 14px; +} +.job-table-head { + height: 34px; border-bottom: 1px solid var(--border); + font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-4); +} +.job-table-row { height: 42px; border-bottom: 1px solid var(--border); } +.job-table-row:last-child { border-bottom: 0; } +.jt-job { display: flex; align-items: center; gap: 8px; color: var(--text-2); font-size: 11.5px; } +.jt-asset { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-1); font-size: 12.5px; } +.jt-node { color: var(--text-3); font-size: 11px; } +.jt-progress { display: flex; align-items: center; } +.jt-bar { display: block; width: 100%; height: 5px; background: var(--bg-3); border-radius: 99px; overflow: hidden; } +.jt-bar > span { display: block; height: 100%; background: var(--accent); border-radius: 99px; transition: width 300ms; } +.jt-eta { color: var(--text-3); font-size: 11px; text-align: right; } + +/* ---- needs attention ---- */ +.attention-panel { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; } +.attn-row { display: flex; align-items: center; gap: 11px; padding: 11px 13px; border-bottom: 1px solid var(--border); } +.attn-row:last-child { border-bottom: 0; } +.attn-sev { width: 26px; height: 26px; flex-shrink: 0; border-radius: var(--r-sm); display: grid; place-items: center; } +.attn-sev.danger { background: var(--danger-soft); color: var(--danger); } +.attn-sev.warning { background: var(--warning-soft); color: var(--warning); } +.attn-body { flex: 1; min-width: 0; } +.attn-title { font-size: 12.5px; font-weight: 500; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.attn-meta { font-size: 10.5px; color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.attn-row .btn { flex-shrink: 0; } + +/* ---- cluster node list ---- */ +.node-list { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; } +.node-row { display: flex; align-items: center; gap: 14px; padding: 11px 14px; border-bottom: 1px solid var(--border); } +.node-row:last-child { border-bottom: 0; } +.node-row.offline { opacity: 0.55; } +.node-row-id { display: flex; align-items: center; gap: 8px; width: 158px; flex-shrink: 0; } +.node-name { font-size: 12px; color: var(--text-1); font-weight: 500; } +.badge.node-role { height: 17px; padding: 0 5px; font-size: 9px; } +.node-row-metrics { flex: 1; display: grid; grid-template-columns: 1fr 1fr auto; gap: 16px; align-items: center; min-width: 0; } +.node-metric { display: flex; align-items: center; gap: 8px; min-width: 0; } +.node-metric-label { font-size: 10px; color: var(--text-4); width: 24px; flex-shrink: 0; letter-spacing: 0.04em; } +.node-metric-bar { flex: 1; height: 5px; background: var(--bg-3); border-radius: 99px; overflow: hidden; min-width: 26px; } +.node-metric-bar > span { display: block; height: 100%; border-radius: 99px; transition: width 300ms; } +.node-metric-text { font-size: 10.5px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; } +.node-gpu { font-size: 10.5px; color: var(--text-3); white-space: nowrap; justify-self: end; } +.node-row-off { flex: 1; color: var(--text-4); font-size: 11.5px; font-family: var(--font-mono); } + +/* ---- footer status bar (overrides earlier .dash-statusbar) ---- */ +.page.dashboard .dash-statusbar { + display: flex; align-items: center; gap: 14px; + margin: 14px 28px 30px; padding-top: 13px; + border-top: 1px solid var(--border); + font-size: 11.5px; color: var(--text-3); font-family: var(--font-mono); +} +.dash-statusbar .sb-item { display: flex; align-items: center; gap: 6px; } +.dash-statusbar .sb-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-4); } +.dash-statusbar .sb-dot.live { background: var(--live); } +.dash-statusbar .sb-dot.run { background: var(--accent); } +.dash-statusbar .sb-dot.fail { background: var(--danger); } +.dash-statusbar .sb-spacer { flex: 1; } +.dash-statusbar .sb-sep { color: var(--text-4); } + +@media (max-width: 1340px) { + .ops-stats.six { grid-template-columns: repeat(3, 1fr); } +} +@media (max-width: 1180px) { + .ops-stats { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 1080px) { + .page.dashboard .dash-grid { grid-template-columns: 1fr; } + .job-table-head, .job-table-row { grid-template-columns: 130px minmax(0, 1fr) 140px 48px; } + .job-table-head span:nth-child(3), .job-table-row .jt-node { display: none; } +}