dragonflight/services/web-ui/public/screens-home.jsx
Zac Gaetano 43011bd794 Merge feat/playout-mcr into main
Playout/MCR, as-run log, redesigned dashboard, capture CIFS/growing-files,
SDI settings, cluster Add Node wizard, homepage refresh.

# Conflicts:
#	services/mam-api/src/routes/cluster.js
#	services/mam-api/src/routes/playout.js
#	services/mam-api/src/scheduler.js
#	services/playout/Dockerfile
#	services/playout/entrypoint.sh
#	services/web-ui/public/screens-home.jsx
2026-05-31 17:46:12 -04:00

911 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// screens-home.jsx
//
// Two routes share this file:
//
// • Home - the launcher. Big-button entry into each section of the MAM.
// Untouched in this rewrite.
//
// • Dashboard - the operations view. Rebuilt as a control-room status
// board, not a SaaS analytics page. Sections render top-down by
// operator priority:
//
// 1. ON AIR - live recorder tiles, full-width
// 2. UP NEXT - single-row strip of next scheduled recordings
// 3. ATTENTION - conditional; only when something failed
// 4. WORK + CLUSTER - two-column dense panels
// 5. STATUS BAR - single mono-text line, bottom
//
// Anything that would just say "all clear" is hidden, not rendered.
function Home({ navigate }) {
const [showDownloads, setShowDownloads] = React.useState(false);
// Pull live counts so the tile subtitles ("34 assets", "0 live", "3 running")
// reflect what's actually in the DB right now, not a stale boot-time cache.
const [cards, setCards] = React.useState({});
// Playout has no /metrics/home card yet (and the playout schema may not be
// migrated on every install); fetch /playout/channels separately and degrade
// silently — the tile just shows "No channels" if the endpoint isn't there.
const [playoutChannels, setPlayoutChannels] = React.useState(null);
React.useEffect(() => {
let cancelled = false;
const load = () => {
window.ZAMPP_API.fetch('/metrics/home?hours=1')
.then(d => { if (!cancelled) setCards(d?.cards || {}); })
.catch(() => {});
window.ZAMPP_API.fetch('/playout/channels')
.then(d => { if (!cancelled) setPlayoutChannels(Array.isArray(d) ? d : []); })
.catch(() => { if (!cancelled) setPlayoutChannels([]); });
};
load();
const t = setInterval(load, 30_000);
return () => { cancelled = true; clearInterval(t); };
}, []);
const { ASSETS = [], RECORDERS = [], JOBS = [], NODES = [] } = window.ZAMPP_DATA || {};
const assetsTotal = cards.assets?.total ?? ASSETS.length;
const liveCount = cards.recorders?.live ?? RECORDERS.filter(r => r.status === 'recording').length;
const totalRecs = cards.recorders?.total ?? RECORDERS.length;
const runningJobs = cards.jobs?.running ?? JOBS.filter(j => j.status === 'running' || j.status === 'queued').length;
const failedJobs = cards.jobs?.failed_total ?? JOBS.filter(j => j.status === 'failed').length;
const nodesOnline = cards.cluster?.online ?? NODES.filter(n => n.status === 'online' || n.online === true).length;
const nodesTotal = cards.cluster?.total ?? NODES.length;
const tiles = [
{
id: 'library',
label: 'Library',
icon: 'library',
tone: 'accent',
sub: assetsTotal > 0 ? assetsTotal.toLocaleString() + ' assets' : 'No assets yet',
desc: 'Browse projects, bins, and assets. Hover-scrub previews.',
},
{
id: 'recorders',
label: 'Recorders',
icon: 'record',
tone: 'live',
sub: liveCount > 0
? liveCount + ' live · ' + totalRecs + ' configured'
: totalRecs + ' configured',
desc: 'SDI · SRT · RTMP ingest. Start, stop, schedule.',
},
{
id: 'playout',
label: 'Playout',
icon: 'signal',
tone: 'accent',
sub: (() => {
if (playoutChannels === null) return '·';
const total = playoutChannels.length;
const onAir = playoutChannels.filter(c => c.status === 'running').length;
if (total === 0) return 'No channels';
if (onAir > 0) return onAir + ' on air · ' + total + ' channel' + (total === 1 ? '' : 's');
return total + ' channel' + (total === 1 ? '' : 's');
})(),
desc: 'Master Control. SDI · NDI · SRT · RTMP playout, playlists, as-run.',
},
{
id: '__downloads',
label: 'Downloads',
icon: 'download',
tone: 'purple',
sub: 'Plugin · Teams ISO',
desc: 'Download the Premiere Pro UXP plugin and the Teams ISO installer.',
},
{
id: 'jobs',
label: 'Jobs',
icon: 'jobs',
tone: failedJobs > 0 ? 'warn' : 'success',
sub: runningJobs > 0
? runningJobs + ' running' + (failedJobs > 0 ? ' · ' + failedJobs + ' failed' : '')
: (failedJobs > 0 ? failedJobs + ' failed' : 'All clear'),
desc: 'Proxy + thumbnail queue. Retry failed jobs.',
},
];
const settingsTile = {
id: 'settings',
label: 'Settings',
icon: 'settings',
tone: 'neutral',
sub: 'S3 · Encoder · Growing files',
desc: 'Storage, proxy encoder, capture SDK, growing-file mode.',
};
const clusterHealthy = !nodesTotal || nodesOnline >= nodesTotal;
// Activity strip (#153): live recorders + last-24h assets + alerts.
const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4);
const recentAssets = (() => {
const dayAgo = Date.now() - 86400000;
return ASSETS
.filter(a => a.created_at && new Date(a.created_at).getTime() > dayAgo)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 6);
})();
const failedCount = JOBS.filter(j => j.status === 'failed').length;
const errCount = RECORDERS.filter(r => r.status === 'error').length;
const hasActivity = liveRecorders.length || recentAssets.length || failedCount || errCount;
return (
<div className="launcher">
<div className="launcher-inner">
<div className="launcher-hero">
<span className="launcher-logo-wrap">
<span className="launcher-logo-pulse" aria-hidden="true" />
<img
className="launcher-logo"
src="img/dragon-logo.png"
alt="Dragonflight"
draggable="false"
/>
</span>
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
<p className="launcher-kicker">Let's Create</p>
<p className="launcher-tagline">
Media Asset Management &amp; Production Platform
</p>
</div>
<div className="launcher-grid">
{tiles.map(t => (
<button
key={t.id}
className={'launcher-tile tone-' + t.tone}
onClick={() => t.id === '__downloads' ? setShowDownloads(true) : navigate(t.id)}
>
<span className="launcher-tile-icon">
<Icon name={t.icon} size={26} />
</span>
<span className="launcher-tile-label">{t.label}</span>
<span className="launcher-tile-sub">{t.sub}</span>
<span className="launcher-tile-desc">{t.desc}</span>
<span className="launcher-tile-arrow">
<Icon name="arrowRight" size={14} />
</span>
</button>
))}
<button
className="launcher-tile tone-ghost launcher-tile-secondary"
onClick={() => navigate('dashboard')}
>
<span className="launcher-tile-icon">
<Icon name="layout" size={22} />
</span>
<span className="launcher-tile-label">Dashboard</span>
<span className="launcher-tile-sub">Operations view</span>
<span className="launcher-tile-desc">
Recent activity, job queue, cluster health.
</span>
<span className="launcher-tile-arrow">
<Icon name="arrowRight" size={14} />
</span>
</button>
</div>
<div className="launcher-settings-row">
<button
className={'launcher-tile tone-' + settingsTile.tone + ' launcher-tile-settings'}
onClick={() => navigate(settingsTile.id)}
>
<span className="launcher-tile-icon">
<Icon name={settingsTile.icon} size={26} />
</span>
<span className="launcher-tile-label">{settingsTile.label}</span>
<span className="launcher-tile-sub">{settingsTile.sub}</span>
<span className="launcher-tile-desc">{settingsTile.desc}</span>
<span className="launcher-tile-arrow">
<Icon name="arrowRight" size={14} />
</span>
</button>
</div>
{hasActivity && (
<div className="launcher-activity">
{(failedCount > 0 || errCount > 0) && (
<div className="launcher-activity-strip alert">
<Icon name="alert" size={14} />
<span>
{errCount > 0 && <strong>{errCount} recorder{errCount === 1 ? '' : 's'} in error.</strong>}
{errCount > 0 && failedCount > 0 && ' '}
{failedCount > 0 && <strong>{failedCount} failed job{failedCount === 1 ? '' : 's'}.</strong>}
</span>
<button className="btn ghost sm" onClick={() => navigate('dashboard')}>Open Dashboard</button>
</div>
)}
{liveRecorders.length > 0 && (
<div className="launcher-activity-section">
<div className="launcher-activity-head">
<span className="rec-dot" />
Recording now
<span className="muted">{liveRecorders.length} live</span>
<div style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={() => navigate('monitors')}>Monitors</button>
</div>
<div className="launcher-activity-grid">
{liveRecorders.map(r => (
<button key={r.id} className="launcher-activity-item" onClick={() => navigate('recorders')}>
<span className="badge live">REC</span>
<span className="launcher-activity-item-name">{r.name}</span>
<span className="launcher-activity-item-meta mono">{r.source_type || 'sdi'}</span>
</button>
))}
</div>
</div>
)}
{recentAssets.length > 0 && (
<div className="launcher-activity-section">
<div className="launcher-activity-head">
<Icon name="library" size={12} />
Last 24 hours
<span className="muted">{recentAssets.length} new asset{recentAssets.length === 1 ? '' : 's'}</span>
<div style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={() => navigate('library')}>Library</button>
</div>
<div className="launcher-activity-grid">
{recentAssets.map(a => (
<button key={a.id} className="launcher-activity-item" onClick={() => navigate('library')}>
<Icon name={a.media_type === 'audio' ? 'audio' : 'video'} size={13} />
<span className="launcher-activity-item-name">{a.display_name || a.filename || 'untitled'}</span>
<span className="launcher-activity-item-meta mono">
{(() => {
const mins = Math.round((Date.now() - new Date(a.created_at)) / 60000);
if (mins < 60) return mins + 'm';
const h = Math.round(mins / 60);
return h + 'h';
})()}
</span>
</button>
))}
</div>
</div>
)}
</div>
)}
<div className="launcher-status">
<span className="launcher-status-pip">
<span
className="dot"
style={{ background: clusterHealthy ? 'var(--success)' : 'var(--warning)' }}
/>
{clusterHealthy ? 'cluster healthy' : 'cluster degraded'}
<span className="muted">· {nodesOnline}/{nodesTotal || 0} node{nodesTotal === 1 ? '' : 's'}</span>
</span>
{liveCount > 0 && (
<span className="launcher-status-pip live">
<span className="dot" style={{ background: 'var(--live)' }} />
{liveCount} recorder{liveCount === 1 ? '' : 's'} live
</span>
)}
</div>
</div>
{showDownloads && <DownloadsModal onClose={() => setShowDownloads(false)} />}
</div>
);
}
// Modal listing all downloads: the Premiere Pro UXP plugin (.ccx, one per
// released version, sourced from window.PREMIERE_RELEASES written by the
// Settings → SDKs section in screens-admin.jsx) plus the Teams ISO installer
// (window.TEAMS_ISO; the .exe slot is wired but the file may still be pending).
function DownloadsModal({ onClose }) {
const teamsIso = window.TEAMS_ISO || {};
const releases = (window.PREMIERE_RELEASES || []).slice().sort((a, b) => {
const av = String(a.version || ''), bv = String(b.version || '');
return bv.localeCompare(av, undefined, { numeric: true });
});
const latest = window.PREMIERE_LATEST || releases[0] || null;
const DRAGON_ISO_RELEASES_URL = 'https://forge.wilddragon.net/WildDragonLLC/dragon-iso/releases';
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 580 }}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Downloads</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>
The Premiere Pro (UXP) plugin and the Teams ISO installer. Install the .ccx per workstation via the Adobe UXP Developer Tool, or double-click it with Creative Cloud installed.
</div>
</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="premiere-release">
<div className="premiere-release-head">
<span className="premiere-release-version mono">Teams ISO</span>
{teamsIso.version && (
<span className="premiere-release-date mono">v{teamsIso.version}</span>
)}
</div>
<div className="premiere-release-notes">
Windows installer for the Teams ISO workstation build.
</div>
<div className="premiere-release-actions">
{teamsIso.available && teamsIso.url ? (
<a href={teamsIso.url} download className="btn primary sm">
<Icon name="download" />Teams ISO (.exe)
</a>
) : (
<>
<span className="btn primary sm" aria-disabled="true" style={{ opacity: 0.5, pointerEvents: 'none' }}>
<Icon name="download" />Teams ISO (.exe)
</span>
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>coming soon file pending</span>
</>
)}
</div>
</div>
{releases.length === 0 && (
<div style={{ padding: '12px 0', color: 'var(--text-3)', fontSize: 12 }}>
No releases registered. Upload one from Settings Capture SDKs.
</div>
)}
{releases.map((rel, i) => (
<div key={rel.version || i} className="premiere-release">
<div className="premiere-release-head">
<span className="premiere-release-version mono">v{rel.version}</span>
{latest && latest.version === rel.version && (
<span className="badge accent">LATEST</span>
)}
{rel.released_at && (
<span className="premiere-release-date mono">
{new Date(rel.released_at).toLocaleDateString()}
</span>
)}
</div>
{rel.notes && <div className="premiere-release-notes">{rel.notes}</div>}
<div className="premiere-release-actions">
{rel.ccx && (
<a href={rel.ccx} download className="btn primary sm">
<Icon name="download" size={12} />UXP plugin (.ccx)
</a>
)}
{rel.installer && (
<a href={rel.installer} download className="btn ghost sm">
<Icon name="download" size={12} />Windows installer
</a>
)}
</div>
</div>
))}
{/* ── Dragon-ISO ── */}
<div className="downloads-section-head" style={{ marginTop: 20 }}>
<Icon name="film" size={13} />
<span>Dragon-ISO</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
NDI ISO recorder for Microsoft Teams. Windows, .NET 8 WPF.
</div>
<div className="premiere-release">
<div className="premiere-release-head">
<span className="premiere-release-version mono">Releases</span>
</div>
<div className="premiere-release-actions">
<a href={DRAGON_ISO_RELEASES_URL} target="_blank" rel="noopener noreferrer" className="btn primary sm">
<Icon name="download" size={12} />View releases on Forgejo
</a>
</div>
</div>
</div>
<div className="modal-foot">
<button className="btn ghost" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────
// 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;
// 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(() => {
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 — surfaces armed/standby sources in the on-air empty state.
const [upcoming, setUpcoming] = React.useState([]);
React.useEffect(() => {
let cancelled = false;
const load = () => {
window.ZAMPP_API.fetch('/schedules?status=upcoming')
.then(d => { if (!cancelled) setUpcoming(d?.schedules || []); })
.catch(() => {});
};
load();
const t = setInterval(load, 30_000);
return () => { cancelled = true; clearInterval(t); };
}, []);
// Refresh jobs frequently - this screen is the failed-job alert surface.
const [jobs, setJobs] = React.useState(JOBS);
React.useEffect(() => {
let cancelled = false;
const load = () => {
window.ZAMPP_API.fetch('/jobs')
.then(raw => {
if (cancelled || !Array.isArray(raw)) return;
const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' };
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', import: 'YouTube' };
const norm = raw.map(j => {
const meta = j.metadata || {};
return {
...j,
status: statusMap[j.status] || j.status,
kind: kindMap[j.type] || j.type || 'Job',
asset: j.asset_name || meta.filename || '·',
node: meta.node || '·',
error: j.error || null,
progress: j.progress || 0,
};
});
window.ZAMPP_DATA.JOBS = norm;
setJobs(norm);
})
.catch(() => {});
};
load();
const t = setInterval(load, 5_000);
return () => { cancelled = true; clearInterval(t); };
}, []);
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');
// 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;
// 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 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>
{/* 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>}
/>
<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>
<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}
/>
{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')} />
)}
<SectionHead
title="Job queue"
sub={runningJobs.length + ' active · ' + queuedJobs.length + ' queued'}
onMore={() => navigate('jobs')}
moreLabel="All jobs"
/>
{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>
</div>
</div>
)}
</div>
{/* ───── 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"
sub={nodesTotal ? onlineNodes + '/' + nodesTotal + ' online' : 'no nodes'}
onMore={() => navigate('cluster')}
moreLabel="All nodes"
/>
{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>
</div>
</div>
)}
{/* Resources panel (live cluster GPU/CPU detail), rendered once. */}
{window.ClusterResources && (
<React.Fragment>
<SectionHead title="Resources" />
<window.ClusterResources />
</React.Fragment>
)}
</div>
</div>
<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 — ported from the design, wired to live recorder/job/node data
// ─────────────────────────────────────────────────────────────────────────
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="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="btn ghost sm" onClick={onMore}>{moreLabel}<Icon name="arrowRight" size={11} /></button>
)}
</div>
);
}
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={'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>
)}
{!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 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={'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>
);
}
window.Home = Home;
window.Dashboard = Dashboard;