dashboard: rebuild as control-room status board (on air / up next / attention / work)
This commit is contained in:
parent
d1f9557dd1
commit
a48e1d9dd7
1 changed files with 444 additions and 216 deletions
|
|
@ -3,12 +3,19 @@
|
|||
// Two routes share this file:
|
||||
//
|
||||
// • Home — the launcher. Big-button entry into each section of the MAM.
|
||||
// This is what you see when you log in / hit /. Resembles the original
|
||||
// first-version landing page.
|
||||
// Untouched in this rewrite.
|
||||
//
|
||||
// • Dashboard — the operations view (recent activity, job queue, cluster,
|
||||
// live recorders). Reachable from the sidebar or from the Home launcher.
|
||||
// This is the React component that used to be `Home`.
|
||||
// • Dashboard — the operations view. Rebuilt as a control-room status
|
||||
// board, not a SaaS analytics page. Sections render top-down by
|
||||
// operator priority:
|
||||
//
|
||||
// 1. ON AIR — live recorder tiles, full-width
|
||||
// 2. UP NEXT — single-row strip of next scheduled recordings
|
||||
// 3. ATTENTION — conditional; only when something failed
|
||||
// 4. WORK + CLUSTER — two-column dense panels
|
||||
// 5. STATUS BAR — single mono-text line, bottom
|
||||
//
|
||||
// Anything that would just say "all clear" is hidden, not rendered.
|
||||
|
||||
function Home({ navigate }) {
|
||||
// Pull live counts so the tile subtitles ("34 assets", "0 live", "3 running")
|
||||
|
|
@ -158,21 +165,27 @@ function Home({ navigate }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Dashboard — broadcast-ops control board
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Dashboard({ navigate }) {
|
||||
const { RECORDERS, JOBS, ASSETS, NODES } = window.ZAMPP_DATA;
|
||||
const { RECORDERS, JOBS, NODES } = window.ZAMPP_DATA;
|
||||
|
||||
// Live (current-state) data from the boot-time data load
|
||||
const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4);
|
||||
const runningJobs = JOBS.filter(j => j.status === 'running' || j.status === 'queued');
|
||||
const recentAssets = [...ASSETS].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).slice(0, 6);
|
||||
// Live state — recompute every second so elapsed timers keep ticking.
|
||||
const [tick, setTick] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
const i = setInterval(() => setTick(t => t + 1), 1000);
|
||||
return () => clearInterval(i);
|
||||
}, []);
|
||||
|
||||
// Real historic sparklines from /metrics/home — buckets the last 24h.
|
||||
const [metrics, setMetrics] = React.useState(null);
|
||||
// Upcoming schedule for UP NEXT strip.
|
||||
const [upcoming, setUpcoming] = React.useState([]);
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = () => {
|
||||
window.ZAMPP_API.fetch('/metrics/home?hours=24')
|
||||
.then(d => { if (!cancelled) setMetrics(d); })
|
||||
window.ZAMPP_API.fetch('/schedules?status=upcoming')
|
||||
.then(d => { if (!cancelled) setUpcoming(d?.schedules || []); })
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
|
|
@ -180,236 +193,451 @@ function Dashboard({ navigate }) {
|
|||
return () => { cancelled = true; clearInterval(t); };
|
||||
}, []);
|
||||
|
||||
const cards = metrics?.cards || {};
|
||||
const vals = (s) => Array.isArray(s) ? s.map(p => p.v) : [];
|
||||
// Refresh jobs frequently — this screen is the failed-job alert surface.
|
||||
const [jobs, setJobs] = React.useState(JOBS);
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = () => {
|
||||
window.ZAMPP_API.fetch('/jobs')
|
||||
.then(raw => {
|
||||
if (cancelled || !Array.isArray(raw)) return;
|
||||
const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' };
|
||||
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', import: 'YouTube' };
|
||||
const norm = raw.map(j => {
|
||||
const meta = j.metadata || {};
|
||||
return {
|
||||
...j,
|
||||
status: statusMap[j.status] || j.status,
|
||||
kind: kindMap[j.type] || j.type || 'Job',
|
||||
asset: j.asset_name || meta.filename || '—',
|
||||
node: meta.node || '—',
|
||||
error: j.error || null,
|
||||
progress: j.progress || 0,
|
||||
};
|
||||
});
|
||||
window.ZAMPP_DATA.JOBS = norm;
|
||||
setJobs(norm);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const t = setInterval(load, 5_000);
|
||||
return () => { cancelled = true; clearInterval(t); };
|
||||
}, []);
|
||||
|
||||
// Card values come from /metrics so that "Library" reflects what's in the
|
||||
// DB right now, not whatever ZAMPP_DATA happens to be cached as on first load.
|
||||
const assetsTotal = cards.assets?.total ?? ASSETS.length;
|
||||
const liveCount = cards.recorders?.live ?? liveRecorders.length;
|
||||
const totalRecs = cards.recorders?.total ?? RECORDERS.length;
|
||||
const runningCount = cards.jobs?.running ?? runningJobs.length;
|
||||
const doneCount = cards.jobs?.done_total ?? JOBS.filter(j => j.status === 'done').length;
|
||||
const failedCount = cards.jobs?.failed_total ?? JOBS.filter(j => j.status === 'failed').length;
|
||||
const nodesOnline = cards.cluster?.online ?? NODES.filter(n => n.status === 'online' || n.online === true).length;
|
||||
const nodesTotal = cards.cluster?.total ?? NODES.length;
|
||||
const liveRecorders = RECORDERS.filter(r => r.status === 'recording');
|
||||
const erroredRecorders = RECORDERS.filter(r => r.status === 'error');
|
||||
const runningJobs = jobs.filter(j => j.status === 'running');
|
||||
const queuedJobs = jobs.filter(j => j.status === 'queued');
|
||||
const failedJobs = jobs.filter(j => j.status === 'failed');
|
||||
const doneJobs = jobs.filter(j => j.status === 'done');
|
||||
const offlineNodes = NODES.filter(n => !(n.status === 'online' || n.online === true));
|
||||
const onlineNodes = NODES.length - offlineNodes.length;
|
||||
|
||||
// Sum the most recent hour of each bucketed series for the delta line so
|
||||
// the "+N this hour" hint always reflects the latest bucket.
|
||||
const sumWindow = (series) => (Array.isArray(series) ? series.reduce((a, p) => a + p.v, 0) : 0);
|
||||
// Next 3 upcoming, sorted, future only
|
||||
const nowMs = Date.now();
|
||||
const nextUp = upcoming
|
||||
.filter(s => new Date(s.start_at).getTime() > nowMs && s.status === 'pending')
|
||||
.slice(0, 3);
|
||||
|
||||
const hasAttention = failedJobs.length > 0 || offlineNodes.length > 0 || erroredRecorders.length > 0;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="dash-stat-row">
|
||||
<div className="dash-stat" onClick={() => navigate('library')} style={{ cursor: 'pointer' }}>
|
||||
<div className="dash-stat-top">
|
||||
<span className="dash-stat-label">Library</span>
|
||||
<Icon name="library" size={13} className="dash-stat-ico" />
|
||||
</div>
|
||||
<div className="dash-stat-mono">{assetsTotal.toLocaleString()}</div>
|
||||
<div className="dash-stat-sub">
|
||||
{sumWindow(cards.assets?.series) > 0
|
||||
? '+' + sumWindow(cards.assets?.series) + ' in 24h'
|
||||
: 'total assets'}
|
||||
</div>
|
||||
<DashSparkline data={vals(cards.assets?.series)} color="#5B7CFA" />
|
||||
</div>
|
||||
<div className="dash-stat" onClick={() => navigate('recorders')} style={{ cursor: 'pointer' }}>
|
||||
<div className="dash-stat-top">
|
||||
<span className="dash-stat-label">Live feeds</span>
|
||||
<Icon name="record" size={13} className="dash-stat-ico" />
|
||||
</div>
|
||||
<div className="dash-stat-mono">{liveCount}</div>
|
||||
<div className="dash-stat-sub">{totalRecs} configured</div>
|
||||
<DashSparkline data={vals(cards.recorders?.series)} color="#2DD4A8" />
|
||||
</div>
|
||||
<div className="dash-stat" onClick={() => navigate('jobs')} style={{ cursor: 'pointer' }}>
|
||||
<div className="dash-stat-top">
|
||||
<span className="dash-stat-label">Jobs</span>
|
||||
<Icon name="jobs" size={13} className="dash-stat-ico" />
|
||||
</div>
|
||||
<div className="dash-stat-mono">{runningCount}<span className="dash-stat-mono-sub"> / {doneCount}</span></div>
|
||||
<div className="dash-stat-sub" style={{ color: failedCount > 0 ? 'var(--warning)' : '' }}>
|
||||
{failedCount > 0 ? failedCount + ' failed' : 'All clear'}
|
||||
{sumWindow(cards.jobs?.series_done) > 0 && (
|
||||
<> · {sumWindow(cards.jobs?.series_done)} in 24h</>
|
||||
)}
|
||||
</div>
|
||||
<DashSparkline data={vals(cards.jobs?.series_done)} color="#B57CFA" />
|
||||
</div>
|
||||
<div className="dash-stat" onClick={() => navigate('cluster')} style={{ cursor: 'pointer' }}>
|
||||
<div className="dash-stat-top">
|
||||
<span className="dash-stat-label">Cluster</span>
|
||||
<Icon name="hdd" size={13} className="dash-stat-ico" />
|
||||
</div>
|
||||
<div className="dash-stat-mono">{nodesOnline}<span className="dash-stat-mono-sub"> / {nodesTotal}</span></div>
|
||||
<div className="dash-stat-sub">nodes online</div>
|
||||
<DashSparkline data={vals(cards.cluster?.series)} color="#F5A623" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="home-grid">
|
||||
<div>
|
||||
{liveRecorders.length > 0 && (
|
||||
<>
|
||||
<SectionHead title="Live now" onMore={() => navigate('recorders')} moreLabel="All recorders" />
|
||||
<div className="live-feed-grid">
|
||||
{liveRecorders.map(r => (
|
||||
<div key={r.id} className="live-feed-tile" onClick={() => navigate('recorders')}>
|
||||
<div className="live-feed-tile-badge"><span className="badge live">REC</span></div>
|
||||
{r.live_asset_id
|
||||
? <HlsPreview assetId={r.live_asset_id} />
|
||||
: <FauxFrame />}
|
||||
<div className="live-feed-tile-label">
|
||||
<span className="name">{r.name}</span>
|
||||
{r.source && <span className="source">{r.source}</span>}
|
||||
<span className="time">{r.elapsed}</span>
|
||||
</div>
|
||||
{r.project && (
|
||||
<div className="live-feed-tile-project">
|
||||
<span className="live-feed-project-dot" style={{ background: r.project_color || 'var(--text-3)' }} />
|
||||
<span>{r.project}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ height: 16 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SectionHead title="Recently added" onMore={() => navigate('library')} moreLabel="All assets" />
|
||||
<div className="activity-feed">
|
||||
{recentAssets.length === 0 ? (
|
||||
<div className="dash-empty">No assets yet.</div>
|
||||
) : recentAssets.map(a => (
|
||||
<div key={a.id} className="activity-row">
|
||||
<div className="activity-icon record">
|
||||
<Icon name={a.type === 'audio' ? 'audio' : 'video'} size={12} />
|
||||
</div>
|
||||
<div className="activity-text">
|
||||
<span className="target">{a.name}</span>
|
||||
{a.project && <> in <strong>{a.project}</strong></>}
|
||||
</div>
|
||||
<div className="activity-meta">
|
||||
{a.duration && <span className="activity-dur">{a.duration}</span>}
|
||||
{a.res && <span className="activity-res">{a.res}</span>}
|
||||
</div>
|
||||
<div className="activity-time">{a.updated}</div>
|
||||
</div>
|
||||
<div className="page dash">
|
||||
{/* ────────── 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"
|
||||
/>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<SectionHead title="Job queue" onMore={() => navigate('jobs')} moreLabel="View all" />
|
||||
<div className="panel" style={{ padding: 4 }}>
|
||||
{JOBS.length === 0
|
||||
? <div className="dash-empty-panel">No jobs.</div>
|
||||
: JOBS.slice(0, 5).map(j => <MiniJobRow key={j.id} job={j} />)}
|
||||
{/* ────────── 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-next-row">
|
||||
{nextUp.map(s => (
|
||||
<UpNextCard key={s.id} schedule={s} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div style={{ height: 16 }} />
|
||||
<SectionHead title="Cluster" onMore={() => navigate('cluster')} moreLabel="View all" />
|
||||
<div className="panel" style={{ padding: 4 }}>
|
||||
{NODES.length === 0
|
||||
? <div className="dash-empty-panel">No nodes found.</div>
|
||||
: NODES.slice(0, 4).map(n => {
|
||||
const nodeId = n.id || n.hostname || n.name || 'node';
|
||||
const isOnline = n.status === 'online' || n.online === true;
|
||||
const cpuPct = n.cpu_percent ?? n.cpu ?? n.cpu_usage ?? null;
|
||||
const memRaw = n.memory_used_gb ?? n.mem ?? (n.mem_used_mb != null ? n.mem_used_mb / 1024 : null);
|
||||
const memGb = memRaw != null ? Number(memRaw) : null;
|
||||
const memTotal = n.memory_total_gb ?? n.mem_total_gb ?? null;
|
||||
const memPct = memGb && memTotal ? Math.round((memGb / memTotal) * 100) : null;
|
||||
return (
|
||||
<div key={nodeId} className="cluster-node-row">
|
||||
<StatusDot status={isOnline ? 'online' : 'offline'} />
|
||||
<span className="cluster-node-name">{nodeId}</span>
|
||||
<span className="cluster-node-spacer" />
|
||||
{cpuPct != null && (
|
||||
<span className="cluster-node-metric">
|
||||
<span className="cluster-node-metric-label">CPU</span>
|
||||
<span className="cluster-node-bar">
|
||||
<span className="cluster-node-bar-fill" style={{ width: Math.round(cpuPct) + '%', background: cpuPct > 80 ? 'var(--warning)' : 'var(--text-3)' }} />
|
||||
</span>
|
||||
<span className="cluster-node-metric-val">{Math.round(cpuPct)}%</span>
|
||||
</span>
|
||||
)}
|
||||
{memGb != null && (
|
||||
<span className="cluster-node-metric">
|
||||
<span className="cluster-node-metric-label">MEM</span>
|
||||
<span className="cluster-node-bar">
|
||||
<span className="cluster-node-bar-fill" style={{ width: (memPct || Math.round((memGb / 32) * 100)) + '%', background: (memPct || 0) > 85 ? 'var(--warning)' : 'var(--text-3)' }} />
|
||||
</span>
|
||||
<span className="cluster-node-metric-val">{memGb < 1 ? Math.round(memGb * 1024) + 'MB' : memGb.toFixed(1) + 'GB'}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* ────────── 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
|
||||
title="Job queue"
|
||||
count={runningJobs.length + queuedJobs.length}
|
||||
countLabel="active"
|
||||
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>
|
||||
</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>
|
||||
|
||||
{/* CLUSTER */}
|
||||
<div className="dash-col">
|
||||
<DashSectionHead
|
||||
title="Cluster"
|
||||
count={onlineNodes}
|
||||
countLabel={NODES.length ? 'of ' + NODES.length + ' 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>
|
||||
</div>
|
||||
{NODES.slice(0, 6).map(n => <DashClusterRow key={n.id || n.hostname || n.name} node={n} />)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function DashSparkline({ data, color }) {
|
||||
if (!data || data.length < 2) return <div className="dash-sparkline" />;
|
||||
const max = Math.max(...data, 1);
|
||||
const min = Math.min(...data, 0);
|
||||
const range = max - min || 1;
|
||||
const h = 24;
|
||||
const w = 80;
|
||||
const step = w / (data.length - 1);
|
||||
const pts = data.map((d, i) => (i * step) + ',' + (h - ((d - min) / range) * h)).join(' ');
|
||||
const area = '0,' + h + ' ' + pts + ' ' + w + ',' + h;
|
||||
return (
|
||||
<div className="dash-sparkline">
|
||||
<svg viewBox={'0 0 ' + w + ' ' + h} preserveAspectRatio="none" style={{ width: '100%', height: h, display: 'block' }}>
|
||||
<polygon points={area} fill={color} opacity="0.12" />
|
||||
<polyline points={pts} fill="none" stroke={color} strokeWidth="1.2" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Subcomponents
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionHead({ title, onMore, moreLabel = 'View all' }) {
|
||||
function DashSectionHead({ title, accent, count, countLabel, onMore, moreLabel }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', padding: '16px 0 10px', gap: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, letterSpacing: '-0.01em', whiteSpace: 'nowrap' }}>{title}</div>
|
||||
<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>
|
||||
)}
|
||||
{onMore && (
|
||||
<button className="btn ghost sm" style={{ marginLeft: 'auto', whiteSpace: 'nowrap' }} onClick={onMore}>
|
||||
{moreLabel}<Icon name="arrowRight" size={11} />
|
||||
<button className="dash-sectionhead-more" onClick={onMore}>
|
||||
{moreLabel || 'View all'}
|
||||
<Icon name="arrowRight" size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniJobRow({ job }) {
|
||||
function DashInlineEmpty({ icon, text, cta, onCta }) {
|
||||
return (
|
||||
<div className="mini-job-row">
|
||||
<StatusDot status={job.status} />
|
||||
<div className="mini-job-body">
|
||||
<div className="mini-job-line1">
|
||||
<span className="mini-job-kind">{job.kind}</span>
|
||||
<span className="mini-job-sep">·</span>
|
||||
<span className="mini-job-asset">{job.asset}</span>
|
||||
{job.node && <span className="mini-job-node">on {job.node}</span>}
|
||||
<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} />
|
||||
: <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' && (
|
||||
<div className="mini-job-progress">
|
||||
<div className="mini-job-progress-fill" style={{ width: (job.progress || 0) + '%' }} />
|
||||
<span className="mini-job-pct">{job.progress || 0}%</span>
|
||||
</div>
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{job.status === 'failed' && <div className="mini-job-error">{job.error}</div>}
|
||||
</div>
|
||||
<div className="mini-job-right">
|
||||
{job.status === 'running' && job.eta ? job.eta : <span className={'badge ' + job.status}>{job.status}</span>}
|
||||
</div>
|
||||
{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>
|
||||
</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
|
||||
? Math.round((memUsed / memTotal) * 100)
|
||||
: (memUsed != null ? Math.min(100, Math.round((memUsed / 32) * 100)) : null);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue