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 && (
+
+
+
+
+ )}
+
+ 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 && (
-
- {moreLabel || 'View all'}
-
-
+ {moreLabel}
)}
);
}
-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 && (
-
- {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' && (
- <>
-
-
-
- {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.
+
+
Start a recorder
+
+ {sources.length > 0 && (
+
+ {sources.slice(0, 4).map(s => (
+
+
+ {s.name}
+ {(s.source || s.source_type) && {s.source || s.source_type} }
+ {s.status === 'armed' ? 'Armed' : 'Start'}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function JobQueueTable({ jobs }) {
+ return (
+
+
+ Job Asset Node Progress ETA
+
+ {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 (
+
+
+
+
navigate(a.to)}>{a.action}
+
+ );
+}
+
+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.
+ : (
+
+
+ Time Clip Duration Result
+
+
+ {rows.slice(0, 50).map((r) => (
+
+ {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; }
+}