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 <noreply@anthropic.com>
This commit is contained in:
parent
7451d7c703
commit
f21bc490e8
5 changed files with 767 additions and 394 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="page dash">
|
||||
<div className="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
<span className="subtitle">Live operations: on-air recorders, jobs, cluster health</span>
|
||||
<div className="spacer" />
|
||||
{hasAttention && (
|
||||
<span className="badge danger" title="Items need attention">
|
||||
<Icon name="alert" size={10} />
|
||||
{failedJobs.length + offlineNodes.length + erroredRecorders.length} alert{failedJobs.length + offlineNodes.length + erroredRecorders.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
<span className="status-pip">
|
||||
<span className="dot" style={{ background: offlineNodes.length === 0 ? 'var(--success)' : 'var(--warning)' }} />
|
||||
<span>{onlineNodes}/{NODES.length || 0} nodes online</span>
|
||||
</span>
|
||||
<div className="page dashboard">
|
||||
<div className="ops-header">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<div className="ops-sub">Live operations · on-air recorders, jobs, and cluster health</div>
|
||||
</div>
|
||||
<div className="ops-header-right">
|
||||
<div className="ops-nodes-pill">
|
||||
<StatusDot status={offlineNodes.length ? 'processing' : 'online'} />
|
||||
{onlineNodes}/{nodesTotal} nodes online
|
||||
</div>
|
||||
<Clock />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ────────── ON AIR ────────── */}
|
||||
<section className="dash-section">
|
||||
<DashSectionHead
|
||||
title="On air"
|
||||
accent="live"
|
||||
count={liveRecorders.length}
|
||||
countLabel={liveRecorders.length === 1 ? 'recorder live' : 'recorders live'}
|
||||
onMore={() => navigate('recorders')}
|
||||
moreLabel="All recorders"
|
||||
{/* Status strip — only cells backed by a real endpoint. */}
|
||||
<div className="ops-stats">
|
||||
<StatCell label="On air" value={liveRecorders.length} unit="live" foot={
|
||||
<span className="stat-pips">
|
||||
<span className={'stat-pip armed ' + (armedRecorders.length ? '' : 'zero')}><i />{armedRecorders.length} armed</span>
|
||||
<span className={'stat-pip idle ' + (idleRecorders.length ? '' : 'zero')}><i />{idleRecorders.length} idle</span>
|
||||
</span>
|
||||
} />
|
||||
<StatCell
|
||||
label="Assets · 24h"
|
||||
value={assets24h == null ? '—' : assets24h}
|
||||
foot={<span>{(home?.cards?.assets?.total ?? '—')} total in library</span>}
|
||||
/>
|
||||
{liveRecorders.length === 0 ? (
|
||||
<DashInlineEmpty
|
||||
icon="record"
|
||||
text="No recorders on air."
|
||||
cta="Start a recorder"
|
||||
onCta={() => navigate('recorders')}
|
||||
/>
|
||||
) : (
|
||||
<div className="dash-onair-grid" data-count={Math.min(liveRecorders.length, 4)}>
|
||||
{liveRecorders.slice(0, 4).map(r => (
|
||||
<OnAirTile key={r.id} recorder={r} onClick={() => navigate('recorders')} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<StatCell label="Jobs" value={runningJobs.length} unit="active" foot={
|
||||
<span>
|
||||
{queuedJobs.length} queued · {doneJobs.length} done
|
||||
{failedJobs.length ? <span className="foot-danger"> · {failedJobs.length} failed</span> : null}
|
||||
</span>
|
||||
} />
|
||||
<StatCell label="Cluster" value={nodesTotal ? onlineNodes + '/' + nodesTotal : '—'} unit={nodesTotal ? 'nodes' : ''} foot={
|
||||
offlineNodes.length
|
||||
? <span className="foot-warn">{offlineNodes[0].hostname || offlineNodes[0].id} offline</span>
|
||||
: <span>{nodesTotal ? 'all healthy' : 'no nodes registered'}</span>
|
||||
} />
|
||||
</div>
|
||||
|
||||
{/* ────────── UP NEXT ────────── */}
|
||||
{nextUp.length > 0 && (
|
||||
<section className="dash-section">
|
||||
<DashSectionHead
|
||||
title="Up next"
|
||||
accent="accent"
|
||||
count={nextUp.length}
|
||||
countLabel="scheduled"
|
||||
onMore={() => navigate('schedule')}
|
||||
moreLabel="Schedule"
|
||||
<div className="dash-grid">
|
||||
{/* ───── MAIN: On air + Job queue ───── */}
|
||||
<div className="dash-main">
|
||||
<SectionHead
|
||||
title="On air"
|
||||
sub={liveRecorders.length ? liveRecorders.length + ' recording' : 'standby'}
|
||||
onMore={() => navigate('recorders')}
|
||||
moreLabel="All recorders"
|
||||
live={liveRecorders.length > 0}
|
||||
/>
|
||||
<div className="dash-next-row">
|
||||
{nextUp.map(s => (
|
||||
<UpNextCard key={s.id} schedule={s} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{liveRecorders.length > 0 ? (
|
||||
<div className="live-now-grid">
|
||||
{[...liveRecorders, ...armedRecorders].slice(0, 6).map((r, i) => (
|
||||
<IngestTile key={r.id} r={r} seed={i + 1} onClick={() => navigate('recorders')} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<OnAirEmpty sources={standbySources} onStart={() => navigate('recorders')} />
|
||||
)}
|
||||
|
||||
{/* ────────── ATTENTION ────────── */}
|
||||
{hasAttention && (
|
||||
<section className="dash-section dash-attention">
|
||||
<DashSectionHead
|
||||
title="Needs attention"
|
||||
accent="danger"
|
||||
count={failedJobs.length + offlineNodes.length + erroredRecorders.length}
|
||||
countLabel="items"
|
||||
/>
|
||||
<div className="panel dash-attention-panel">
|
||||
{erroredRecorders.map(r => (
|
||||
<AttentionRow
|
||||
key={'r-' + r.id}
|
||||
level="danger"
|
||||
icon="record"
|
||||
title={r.name}
|
||||
detail={'recorder error' + (r.error_message ? ' · ' + r.error_message : '')}
|
||||
onClick={() => navigate('recorders')}
|
||||
/>
|
||||
))}
|
||||
{offlineNodes.map(n => (
|
||||
<AttentionRow
|
||||
key={'n-' + (n.id || n.hostname)}
|
||||
level="warning"
|
||||
icon="cluster"
|
||||
title={n.hostname || n.id}
|
||||
detail="node offline · no heartbeat for >2 min"
|
||||
onClick={() => navigate('cluster')}
|
||||
/>
|
||||
))}
|
||||
{failedJobs.slice(0, 5).map(j => (
|
||||
<AttentionRow
|
||||
key={'j-' + j.id}
|
||||
level="danger"
|
||||
icon="alert"
|
||||
title={j.kind + ' failed'}
|
||||
detail={(j.asset || '·') + (j.error ? ' · ' + j.error.slice(0, 100) : '')}
|
||||
onClick={() => navigate('jobs')}
|
||||
/>
|
||||
))}
|
||||
{failedJobs.length > 5 && (
|
||||
<div className="dash-attention-more" onClick={() => navigate('jobs')}>
|
||||
+{failedJobs.length - 5} more failed jobs
|
||||
<Icon name="arrowRight" size={12} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ────────── WORK + CLUSTER ────────── */}
|
||||
<section className="dash-grid">
|
||||
{/* JOB QUEUE */}
|
||||
<div className="dash-col">
|
||||
<DashSectionHead
|
||||
<SectionHead
|
||||
title="Job queue"
|
||||
count={runningJobs.length + queuedJobs.length}
|
||||
countLabel="active"
|
||||
sub={runningJobs.length + ' active · ' + queuedJobs.length + ' queued'}
|
||||
onMore={() => navigate('jobs')}
|
||||
moreLabel="All jobs"
|
||||
/>
|
||||
<div className="panel dash-jobs-panel">
|
||||
{jobs.length === 0 ? (
|
||||
<div className="dash-panel-empty">
|
||||
<Icon name="check" size={14} />
|
||||
Queue clear · {doneJobs.length} done
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="dash-jobs-head">
|
||||
<span></span>
|
||||
<span>Job</span>
|
||||
<span>Asset</span>
|
||||
<span>Progress</span>
|
||||
{orderedJobs.length > 0 ? (
|
||||
<JobQueueTable jobs={orderedJobs} />
|
||||
) : (
|
||||
<div className="onair-empty">
|
||||
<div className="onair-empty-head">
|
||||
<span className="onair-empty-icon"><Icon name="check" size={16} /></span>
|
||||
<div className="onair-empty-copy">
|
||||
<div className="onair-empty-title">Queue clear</div>
|
||||
<div className="onair-empty-sub">{doneJobs.length} job{doneJobs.length === 1 ? '' : 's'} completed.</div>
|
||||
</div>
|
||||
{/* 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 => <DashJobRow key={j.id} job={j} />)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CLUSTER */}
|
||||
<div className="dash-col">
|
||||
<DashSectionHead
|
||||
{/* ───── SIDE: Needs attention + Cluster ───── */}
|
||||
<div className="dash-side">
|
||||
{alerts.length > 0 && (
|
||||
<React.Fragment>
|
||||
<SectionHead title="Needs attention" count={attentionCount} />
|
||||
<div className="attention-panel">
|
||||
{alerts.map(a => <AttentionRow key={a.key} a={a} navigate={navigate} />)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<SectionHead
|
||||
title="Cluster"
|
||||
count={onlineNodes}
|
||||
countLabel={NODES.length ? 'of ' + NODES.length + ' online' : 'no nodes'}
|
||||
sub={nodesTotal ? onlineNodes + '/' + nodesTotal + ' online' : 'no nodes'}
|
||||
onMore={() => navigate('cluster')}
|
||||
moreLabel="All nodes"
|
||||
/>
|
||||
<div className="panel dash-cluster-panel">
|
||||
{NODES.length === 0 ? (
|
||||
<div className="dash-panel-empty">
|
||||
<Icon name="alert" size={14} />
|
||||
No nodes registered
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="dash-cluster-head">
|
||||
<span></span>
|
||||
<span>Host</span>
|
||||
<span>CPU</span>
|
||||
<span>Mem</span>
|
||||
{nodesTotal > 0 ? (
|
||||
<div className="node-list">
|
||||
{clusterNodes.slice(0, 8).map(n => <NodeRow key={n.id || n.hostname || n.name} n={n} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="onair-empty">
|
||||
<div className="onair-empty-head">
|
||||
<span className="onair-empty-icon"><Icon name="alert" size={16} /></span>
|
||||
<div className="onair-empty-copy">
|
||||
<div className="onair-empty-title">No nodes registered</div>
|
||||
<div className="onair-empty-sub">Cluster agents have not reported in.</div>
|
||||
</div>
|
||||
{NODES.slice(0, 6).map(n => <DashClusterRow key={n.id || n.hostname || n.name} node={n} />)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resources panel (live cluster GPU/CPU detail), rendered once. */}
|
||||
{window.ClusterResources && (
|
||||
<React.Fragment>
|
||||
<SectionHead title="Resources" />
|
||||
<window.ClusterResources />
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ────────── RESOURCES ────────── */}
|
||||
<section className="dash-section">
|
||||
<DashSectionHead title="Resources" />
|
||||
{window.ClusterResources && <window.ClusterResources />}
|
||||
</section>
|
||||
|
||||
{/* ────────── RESOURCES ────────── */}
|
||||
<section className="dash-section">
|
||||
<DashSectionHead title="Resources" />
|
||||
{window.ClusterResources && React.createElement(window.ClusterResources)}
|
||||
</section>
|
||||
|
||||
{/* ────────── STATUS BAR (bottom) ────────── */}
|
||||
<footer className="dash-statusbar">
|
||||
<span className="dash-stat-pip" data-tone={liveRecorders.length > 0 ? 'live' : 'idle'}>
|
||||
<span className="dash-pip-dot" />
|
||||
<span className="dash-pip-num">{liveRecorders.length}</span>
|
||||
<span className="dash-pip-label">live</span>
|
||||
</span>
|
||||
<span className="dash-statusbar-sep">·</span>
|
||||
<span className="dash-stat-pip" data-tone={runningJobs.length > 0 ? 'accent' : 'idle'}>
|
||||
<span className="dash-pip-num">{runningJobs.length}</span>
|
||||
<span className="dash-pip-label">running</span>
|
||||
</span>
|
||||
<span className="dash-statusbar-sep">·</span>
|
||||
<span className="dash-stat-pip">
|
||||
<span className="dash-pip-num">{queuedJobs.length}</span>
|
||||
<span className="dash-pip-label">queued</span>
|
||||
</span>
|
||||
<span className="dash-statusbar-sep">·</span>
|
||||
<span className="dash-stat-pip" data-tone={failedJobs.length > 0 ? 'warning' : 'idle'}>
|
||||
<span className="dash-pip-num">{failedJobs.length}</span>
|
||||
<span className="dash-pip-label">failed</span>
|
||||
</span>
|
||||
<span className="dash-statusbar-spacer" />
|
||||
<span className="dash-stat-pip" data-tone={offlineNodes.length === 0 ? 'success' : 'warning'}>
|
||||
<span className="dash-pip-num">{onlineNodes}/{NODES.length}</span>
|
||||
<span className="dash-pip-label">nodes online</span>
|
||||
</span>
|
||||
<span className="dash-statusbar-sep">·</span>
|
||||
<span className="dash-statusbar-clock">{new Date(nowMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</span>
|
||||
</footer>
|
||||
<div className="dash-statusbar">
|
||||
<span className="sb-item"><i className="sb-dot live" />{liveRecorders.length} live</span>
|
||||
<span className="sb-item"><i className="sb-dot run" />{runningJobs.length} running</span>
|
||||
<span className="sb-item"><i className="sb-dot" />{queuedJobs.length} queued</span>
|
||||
<span className="sb-item"><i className={'sb-dot ' + (failedJobs.length ? 'fail' : '')} />{failedJobs.length} failed</span>
|
||||
<span className="sb-spacer" />
|
||||
<span className="sb-item">{onlineNodes}/{nodesTotal} nodes online</span>
|
||||
<span className="sb-sep">·</span>
|
||||
<span className="sb-item mono"><ClockTime /></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 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 (
|
||||
<div className="dash-sectionhead" data-accent={accent || 'neutral'}>
|
||||
<span className="dash-sectionhead-title">{title}</span>
|
||||
{typeof count === 'number' && (
|
||||
<span className="dash-sectionhead-count">
|
||||
<span className="dash-sectionhead-num">{count}</span>
|
||||
{countLabel && <span className="dash-sectionhead-label">{countLabel}</span>}
|
||||
</span>
|
||||
)}
|
||||
<div className="ops-clock" title="System time">
|
||||
<span className="ops-clock-dot" />
|
||||
<span className="ops-clock-time mono">{pad(t.getHours())}:{pad(t.getMinutes())}:{pad(t.getSeconds())}</span>
|
||||
<span className="ops-clock-day">{days[t.getDay()]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClockTime() {
|
||||
const t = useNow();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return <span>{pad(t.getHours())}:{pad(t.getMinutes())}:{pad(t.getSeconds())}</span>;
|
||||
}
|
||||
|
||||
function StatCell({ label, value, unit, foot }) {
|
||||
return (
|
||||
<div className="stat-cell">
|
||||
<div className="stat-cell-label">{label}</div>
|
||||
<div className="stat-cell-value">{value}{unit ? <span className="stat-cell-unit">{unit}</span> : null}</div>
|
||||
<div className="stat-cell-foot">{foot}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title, sub, count, onMore, moreLabel = 'View all', live }) {
|
||||
return (
|
||||
<div className="section-head">
|
||||
{live && <span className="section-head-live" />}
|
||||
<span className="section-head-title">{title}</span>
|
||||
{count != null && <span className="section-head-count">{count}</span>}
|
||||
{sub && <span className="section-head-sub">{sub}</span>}
|
||||
{onMore && (
|
||||
<button className="dash-sectionhead-more" onClick={onMore}>
|
||||
{moreLabel || 'View all'}
|
||||
<Icon name="arrowRight" size={11} />
|
||||
</button>
|
||||
<button className="btn ghost sm" onClick={onMore}>{moreLabel}<Icon name="arrowRight" size={11} /></button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="dash-inline-empty">
|
||||
<Icon name={icon} size={13} />
|
||||
<span>{text}</span>
|
||||
{cta && onCta && (
|
||||
<button className="dash-inline-empty-cta" onClick={onCta}>
|
||||
{cta} <Icon name="arrowRight" size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OnAirTile({ recorder, onClick }) {
|
||||
return (
|
||||
<div className="dash-onair-tile" onClick={onClick}>
|
||||
<div className="dash-onair-video">
|
||||
{recorder.live_asset_id
|
||||
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
|
||||
: <FauxFrame />}
|
||||
<span className="dash-onair-rec-pip">
|
||||
<span className="dash-onair-rec-dot" />
|
||||
REC
|
||||
</span>
|
||||
<span className="dash-onair-time">{recorder.elapsed || '00:00:00'}</span>
|
||||
</div>
|
||||
<div className="dash-onair-meta">
|
||||
<div className="dash-onair-name">{recorder.name}</div>
|
||||
<div className="dash-onair-sub">
|
||||
<span className="dash-onair-source">{recorder.source || '·'}</span>
|
||||
{recorder.res && recorder.res !== '·' && (
|
||||
<>
|
||||
<span className="dash-onair-dot">·</span>
|
||||
<span className="dash-onair-res">{recorder.res}</span>
|
||||
</>
|
||||
)}
|
||||
{recorder.codec && recorder.codec !== '·' && (
|
||||
<>
|
||||
<span className="dash-onair-dot">·</span>
|
||||
<span className="dash-onair-codec">{recorder.codec}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={'dash-next-card' + (imminent ? ' imminent' : '')}>
|
||||
<div className="dash-next-time">
|
||||
<span className="dash-next-clock">
|
||||
{start.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<span className="dash-next-rel">{relative}</span>
|
||||
</div>
|
||||
<div className="dash-next-body">
|
||||
<div className="dash-next-name">{schedule.name}</div>
|
||||
<div className="dash-next-sub">
|
||||
<Icon name="record" size={11} />
|
||||
<span>{schedule.recorder_name || 'unbound'}</span>
|
||||
<span className="dash-onair-dot">·</span>
|
||||
<span>{durMin}m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttentionRow({ level, icon, title, detail, onClick }) {
|
||||
return (
|
||||
<div className={'dash-attention-row level-' + level} onClick={onClick}>
|
||||
<span className="dash-attention-icon"><Icon name={icon} size={13} /></span>
|
||||
<span className="dash-attention-title">{title}</span>
|
||||
<span className="dash-attention-detail">{detail}</span>
|
||||
<Icon name="arrowRight" size={12} className="dash-attention-arrow" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashJobRow({ job }) {
|
||||
const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers', YouTube: 'download' };
|
||||
return (
|
||||
<div className="dash-jobs-row" data-status={job.status}>
|
||||
<span className="dash-jobs-status">
|
||||
<StatusDot status={job.status} />
|
||||
</span>
|
||||
<span className="dash-jobs-kind">
|
||||
<Icon name={iconMap[job.kind] || 'jobs'} size={12} />
|
||||
<span>{job.kind}</span>
|
||||
</span>
|
||||
<span className="dash-jobs-asset">{job.asset}</span>
|
||||
<span className="dash-jobs-progress">
|
||||
{job.status === 'running' && (
|
||||
<>
|
||||
<span className="dash-jobs-bar">
|
||||
<span className="dash-jobs-bar-fill" style={{ width: Math.round(job.progress || 0) + '%' }} />
|
||||
</span>
|
||||
<span className="dash-jobs-pct">{Math.round(job.progress || 0)}%</span>
|
||||
</>
|
||||
<div className={'ingest-tile ' + r.status} onClick={onClick}>
|
||||
<div className="ingest-tile-screen">
|
||||
{r.live_asset_id && window.HlsPreview ? (
|
||||
<window.HlsPreview assetId={r.live_asset_id} recorderId={r.id} />
|
||||
) : isAudio ? (
|
||||
<div className="ingest-tile-audio"><Waveform seed={seed * 5} color="var(--accent)" /></div>
|
||||
) : (
|
||||
<React.Fragment><FauxFrame seed={seed} /><div className="scanlines" /></React.Fragment>
|
||||
)}
|
||||
{job.status === 'queued' && <span className="dash-jobs-state">queued</span>}
|
||||
{job.status === 'done' && <span className="dash-jobs-state done">done</span>}
|
||||
{job.status === 'failed' && <span className="dash-jobs-state failed">failed</span>}
|
||||
</span>
|
||||
{!rec && <div className="ingest-tile-veil" />}
|
||||
<div className="ingest-tile-top">
|
||||
{rec ? <span className="badge live">REC</span> : <span className="badge accent">ARMED</span>}
|
||||
{(r.source || r.source_type) && <span className="badge outline">{r.source || r.source_type}</span>}
|
||||
</div>
|
||||
<div className="ingest-tile-bottom">
|
||||
<span className="ingest-tile-name">{r.name}</span>
|
||||
{rec && <span className="ingest-tile-tc mono"><Elapsed seconds={elapsed} live /></span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ingest-tile-foot">
|
||||
<span className="mono">{rec ? (r.bitrate || 'recording') : 'standby'}</span>
|
||||
{(r.res && r.res !== '·') || r.codec ? <span className="dot-sep">·</span> : null}
|
||||
<span>{r.res && r.res !== '·' && r.res !== '—' ? r.res : (r.codec || '')}</span>
|
||||
{r.node && r.node !== '·' && <span className="ingest-tile-node mono">{r.node}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="onair-empty">
|
||||
<div className="onair-empty-head">
|
||||
<span className="onair-empty-icon"><Icon name="record" size={18} /></span>
|
||||
<div className="onair-empty-copy">
|
||||
<div className="onair-empty-title">Nothing on air</div>
|
||||
<div className="onair-empty-sub">All recorders are idle — start a source to begin capturing.</div>
|
||||
</div>
|
||||
<button className="btn primary" onClick={onStart}><Icon name="plus" size={14} />Start a recorder</button>
|
||||
</div>
|
||||
{sources.length > 0 && (
|
||||
<div className="onair-sources">
|
||||
{sources.slice(0, 4).map(s => (
|
||||
<button key={s.id} className="onair-source" onClick={onStart}>
|
||||
<StatusDot status={s.status === 'armed' ? 'armed' : 'idle'} />
|
||||
<span className="onair-source-name">{s.name}</span>
|
||||
{(s.source || s.source_type) && <span className="onair-source-src">{s.source || s.source_type}</span>}
|
||||
<span className="onair-source-go">{s.status === 'armed' ? 'Armed' : 'Start'}<Icon name="arrowRight" size={11} /></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobQueueTable({ jobs }) {
|
||||
return (
|
||||
<div className="job-table">
|
||||
<div className="job-table-head">
|
||||
<span>Job</span><span>Asset</span><span>Node</span><span>Progress</span><span>ETA</span>
|
||||
</div>
|
||||
{jobs.map(j => (
|
||||
<div key={j.id} className="job-table-row">
|
||||
<span className="jt-job">
|
||||
<StatusDot status={j.status} />
|
||||
<span className="mono">{j.kind}</span>
|
||||
</span>
|
||||
<span className="jt-asset" title={j.asset}>{j.asset}</span>
|
||||
<span className="jt-node mono">{j.node}</span>
|
||||
<span className="jt-progress">
|
||||
{j.status === 'running'
|
||||
? <span className="jt-bar"><span style={{ width: Math.round(j.progress || 0) + '%' }} /></span>
|
||||
: <JobChip status={j.status} error={j.error} />}
|
||||
</span>
|
||||
<span className="jt-eta mono">{j.status === 'running' ? (j.eta || '—') : '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobChip({ status, error }) {
|
||||
const map = { done: ['success', 'Done'], queued: ['neutral', 'Queued'], failed: ['danger', 'Failed'] };
|
||||
const [cls, label] = map[status] || ['neutral', status];
|
||||
return <span className={'badge ' + cls} title={error || undefined}>{label}</span>;
|
||||
}
|
||||
|
||||
function AttentionRow({ a, navigate }) {
|
||||
return (
|
||||
<div className="attn-row">
|
||||
<span className={'attn-sev ' + a.sev}><Icon name="alert" size={13} /></span>
|
||||
<div className="attn-body">
|
||||
<div className="attn-title">{a.title}</div>
|
||||
<div className="attn-meta mono">{a.meta}</div>
|
||||
</div>
|
||||
<button className="btn subtle sm" onClick={() => navigate(a.to)}>{a.action}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="dash-cluster-row" data-online={isOnline}>
|
||||
<span className="dash-cluster-status">
|
||||
<StatusDot status={isOnline ? 'online' : 'offline'} />
|
||||
</span>
|
||||
<span className="dash-cluster-name">
|
||||
<span className="dash-cluster-host">{nodeId}</span>
|
||||
{node.role && <span className="dash-cluster-role">{node.role}</span>}
|
||||
</span>
|
||||
<span className="dash-cluster-metric">
|
||||
{cpuPct != null ? (
|
||||
<>
|
||||
<span className="dash-cluster-bar">
|
||||
<span
|
||||
className="dash-cluster-bar-fill"
|
||||
style={{
|
||||
width: Math.round(cpuPct) + '%',
|
||||
background: cpuPct > 85 ? 'var(--warning)' : cpuPct > 60 ? 'var(--accent)' : 'var(--success)',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="dash-cluster-val">{Math.round(cpuPct)}%</span>
|
||||
</>
|
||||
) : <span className="dash-cluster-val muted">·</span>}
|
||||
</span>
|
||||
<span className="dash-cluster-metric">
|
||||
{memPct != null ? (
|
||||
<>
|
||||
<span className="dash-cluster-bar">
|
||||
<span
|
||||
className="dash-cluster-bar-fill"
|
||||
style={{
|
||||
width: memPct + '%',
|
||||
background: memPct > 85 ? 'var(--warning)' : 'var(--text-2)',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="dash-cluster-val">{memUsed < 1 ? Math.round(memUsed * 1024) + 'M' : memUsed.toFixed(1) + 'G'}</span>
|
||||
</>
|
||||
) : <span className="dash-cluster-val muted">·</span>}
|
||||
</span>
|
||||
<div className={'node-row ' + (off ? 'offline' : '')}>
|
||||
<div className="node-row-id">
|
||||
<StatusDot status={off ? 'offline' : 'online'} />
|
||||
<span className="node-name mono">{nodeId}</span>
|
||||
{n.role && <span className={'badge ' + (n.role === 'primary' ? 'accent' : 'neutral') + ' node-role'}>{n.role}</span>}
|
||||
</div>
|
||||
{off ? (
|
||||
<div className="node-row-off">offline</div>
|
||||
) : (
|
||||
<div className="node-row-metrics">
|
||||
{cpuPct != null
|
||||
? <NodeMetric label="CPU" pct={Math.round(cpuPct)} text={Math.round(cpuPct) + '%'} />
|
||||
: <div className="node-metric"><span className="node-metric-label">CPU</span><span className="node-metric-text mono">·</span></div>}
|
||||
{memPct != null
|
||||
? <NodeMetric label="RAM" pct={memPct} text={memUsed != null ? (memUsed < 1 ? Math.round(memUsed * 1024) + 'M' : memUsed.toFixed(1) + 'G') : memPct + '%'} />
|
||||
: <div className="node-metric"><span className="node-metric-label">RAM</span><span className="node-metric-text mono">·</span></div>}
|
||||
<div className="node-gpu mono" title={(Array.isArray(gpus) ? gpus.join(', ') : '') || 'no GPU'}>
|
||||
{gpuCount ? gpuCount + '×GPU' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeMetric({ label, pct, text }) {
|
||||
const color = pct > 85 ? 'var(--danger)' : pct > 60 ? 'var(--warning)' : 'var(--accent)';
|
||||
return (
|
||||
<div className="node-metric">
|
||||
<span className="node-metric-label">{label}</span>
|
||||
<span className="node-metric-bar"><span style={{ width: Math.max(2, pct) + '%', background: color }} /></span>
|
||||
<span className="node-metric-text mono">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="po-asrun">
|
||||
<div className="po-section-label">As-Run Log</div>
|
||||
{rows.length === 0
|
||||
? <div className="mono muted" style={{ padding: '8px 0' }}>No as-run entries yet.</div>
|
||||
: (
|
||||
<table className="po-asrun-table">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Clip</th><th>Duration</th><th>Result</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 50).map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="mono">{fmtTime(r.started_at)}</td>
|
||||
<td>{r.clip_name || r.item_id || '—'}</td>
|
||||
<td className="mono">{r.duration_s != null ? fmtDuration(Number(r.duration_s)) : (r.ended_at ? '—' : 'on air')}</td>
|
||||
<td className={'po-asrun-result po-asrun-' + (r.result || 'played')}>{r.result || 'played'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AsRunPanel channel={ch} refreshKey={engine && engine.currentItemId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue