diff --git a/services/web-ui/public/screens-home.jsx b/services/web-ui/public/screens-home.jsx
index 5a11986..5a7eabf 100644
--- a/services/web-ui/public/screens-home.jsx
+++ b/services/web-ui/public/screens-home.jsx
@@ -3,911 +3,137 @@
// 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.
+// • Dashboard - the operations view. Rebuilt as a control-room status board.
-function Home({ navigate }) {
- const [showDownloads, setShowDownloads] = React.useState(false);
+// ─── DragonFlame ─────────────────────────────────────────────
+// Canvas-based particle flame rendered behind the logo. Each particle rises
+// from the base, fades as it climbs, and shifts hue from deep orange → yellow.
+// Spectral shimmer is added via a secondary "spark" layer with lighter colors.
+function DragonFlame() {
+ const canvasRef = React.useRef(null);
+ const rafRef = React.useRef(null);
- // 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([]); });
+ React.useEffect(function() {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+
+ // Particle pool
+ const W = 160, H = 200;
+ canvas.width = W;
+ canvas.height = H;
+
+ // Particle factory
+ function mkParticle(isSpark) {
+ const x = W * 0.5 + (Math.random() - 0.5) * (isSpark ? 30 : 50);
+ return {
+ x,
+ y: H - 10 - Math.random() * 20,
+ vx: (Math.random() - 0.5) * (isSpark ? 0.6 : 0.9),
+ vy: -(0.8 + Math.random() * (isSpark ? 2.2 : 1.6)),
+ life: 0,
+ maxLife: 50 + Math.random() * (isSpark ? 30 : 50),
+ size: isSpark ? 1 + Math.random() * 2 : 3 + Math.random() * 5,
+ spark: isSpark,
+ wobble: (Math.random() - 0.5) * 0.04,
+ };
+ }
+
+ const COUNT = 90, SPARK_COUNT = 30;
+ const particles = Array.from({ length: COUNT }, function() { return mkParticle(false); });
+ const sparks = Array.from({ length: SPARK_COUNT }, function() { return mkParticle(true); });
+
+ function reset(p) {
+ var n = mkParticle(p.spark);
+ Object.assign(p, n);
+ }
+
+ function draw() {
+ ctx.clearRect(0, 0, W, H);
+
+ // Draw glow base
+ const grad = ctx.createRadialGradient(W * 0.5, H - 5, 0, W * 0.5, H - 5, 55);
+ grad.addColorStop(0, 'rgba(255,120,0,0.18)');
+ grad.addColorStop(0.5, 'rgba(255,70,0,0.07)');
+ grad.addColorStop(1, 'transparent');
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.ellipse(W * 0.5, H - 5, 55, 30, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Update + draw each particle
+ var all = particles.concat(sparks);
+ all.forEach(function(p) {
+ p.life += 1;
+ if (p.life >= p.maxLife) { reset(p); return; }
+
+ var t = p.life / p.maxLife; // 0 → 1
+ // Gentle horizontal drift (wobble)
+ p.vx += p.wobble;
+ p.vx *= 0.98;
+ p.x += p.vx;
+ p.y += p.vy;
+ // Decelerate rise near end
+ p.vy *= 0.99;
+
+ var alpha = p.spark
+ ? Math.sin(t * Math.PI) * 0.85
+ : (t < 0.2 ? t / 0.2 : 1 - (t - 0.2) / 0.8) * 0.7;
+
+ // Colour: deep orange (0°) → orange (20°) → yellow (50°) as t rises
+ var hue = p.spark
+ ? 40 + t * 20 // sparks: golden yellow
+ : 10 + t * 45; // flame: orange→yellow
+ var sat = 100;
+ var lgt = p.spark ? 70 + t * 20 : 50 + t * 20;
+
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ if (p.spark) {
+ // Sparks: small bright dots
+ ctx.fillStyle = 'hsl(' + hue + ',' + sat + '%,' + lgt + '%)';
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, p.size * (1 - t * 0.5), 0, Math.PI * 2);
+ ctx.fill();
+ } else {
+ // Flame particles: soft blurred ellipses
+ var size = p.size * (1 - t * 0.6);
+ var g2 = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, size * 2.2);
+ g2.addColorStop(0, 'hsla(' + hue + ',' + sat + '%,' + lgt + '%,1)');
+ g2.addColorStop(0.4, 'hsla(' + (hue - 8) + ',' + sat + '%,' + (lgt - 10) + '%,0.5)');
+ g2.addColorStop(1, 'transparent');
+ ctx.fillStyle = g2;
+ ctx.beginPath();
+ ctx.ellipse(p.x, p.y, size * 2.2, size * 3.5, 0, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ });
+
+ rafRef.current = requestAnimationFrame(draw);
+ }
+
+ // Check reduced-motion preference
+ var mq = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)');
+ if (mq && mq.matches) {
+ // Static glow only
+ var sg = ctx.createRadialGradient(W * 0.5, H * 0.65, 0, W * 0.5, H * 0.65, 80);
+ sg.addColorStop(0, 'rgba(255,130,0,0.22)');
+ sg.addColorStop(0.6, 'rgba(255,70,0,0.08)');
+ sg.addColorStop(1, 'transparent');
+ ctx.fillStyle = sg;
+ ctx.fillRect(0, 0, W, H);
+ } else {
+ draw();
+ }
+
+ return function() {
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
- 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 (
-
-
-
-
-
-
-
-
DRAGONFLIGHT
-
Let's Create
-
- Media Asset Management & Production Platform
-
-
-
-
- {tiles.map(t => (
- t.id === '__downloads' ? setShowDownloads(true) : navigate(t.id)}
- >
-
-
-
- {t.label}
- {t.sub}
- {t.desc}
-
-
-
-
- ))}
-
- navigate('dashboard')}
- >
-
-
-
- Dashboard
- Operations view
-
- Recent activity, job queue, cluster health.
-
-
-
-
-
-
-
-
- navigate(settingsTile.id)}
- >
-
-
-
- {settingsTile.label}
- {settingsTile.sub}
- {settingsTile.desc}
-
-
-
-
-
-
- {hasActivity && (
-
- {(failedCount > 0 || errCount > 0) && (
-
-
-
- {errCount > 0 && {errCount} recorder{errCount === 1 ? '' : 's'} in error. }
- {errCount > 0 && failedCount > 0 && ' '}
- {failedCount > 0 && {failedCount} failed job{failedCount === 1 ? '' : 's'}. }
-
- navigate('dashboard')}>Open Dashboard
-
- )}
-
- {liveRecorders.length > 0 && (
-
-
-
- Recording now
-
{liveRecorders.length} live
-
-
navigate('monitors')}>Monitors
-
-
- {liveRecorders.map(r => (
- navigate('recorders')}>
- REC
- {r.name}
- {r.source_type || 'sdi'}
-
- ))}
-
-
- )}
-
- {recentAssets.length > 0 && (
-
-
-
- Last 24 hours
-
{recentAssets.length} new asset{recentAssets.length === 1 ? '' : 's'}
-
-
navigate('library')}>Library
-
-
- {recentAssets.map(a => (
- navigate('library')}>
-
- {a.display_name || a.filename || 'untitled'}
-
- {(() => {
- 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';
- })()}
-
-
- ))}
-
-
- )}
-
- )}
-
-
-
-
- {clusterHealthy ? 'cluster healthy' : 'cluster degraded'}
- · {nodesOnline}/{nodesTotal || 0} node{nodesTotal === 1 ? '' : 's'}
-
- {liveCount > 0 && (
-
-
- {liveCount} recorder{liveCount === 1 ? '' : 's'} live
-
- )}
-
-
-
Created by Wild Dragon LLC
-
- {showDownloads &&
setShowDownloads(false)} />}
-
+ return React.createElement('span', { className: 'launcher-logo-pulse' },
+ React.createElement('canvas', { ref: canvasRef })
);
-}
-
-// 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 (
-
-
e.stopPropagation()} style={{ maxWidth: 580 }}>
-
-
-
Downloads
-
- 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.
-
-
-
-
-
-
-
-
- Teams ISO
- {teamsIso.version && (
- v{teamsIso.version}
- )}
-
-
- Windows installer for the Teams ISO workstation build.
-
-
- {teamsIso.available && teamsIso.url ? (
-
- Teams ISO (.exe)
-
- ) : (
- <>
-
- Teams ISO (.exe)
-
-
coming soon, file pending
- >
- )}
-
-
- {releases.length === 0 && (
-
- No releases registered. Upload one from Settings → Capture SDKs.
-
- )}
- {releases.map((rel, i) => (
-
-
- v{rel.version}
- {latest && latest.version === rel.version && (
- LATEST
- )}
- {rel.released_at && (
-
- {new Date(rel.released_at).toLocaleDateString()}
-
- )}
-
- {rel.notes &&
{rel.notes}
}
-
-
- ))}
-
- {/* ── Dragon-ISO ── */}
-
-
- Dragon-ISO
-
-
- NDI ISO recorder for Microsoft Teams. Windows, .NET 8 WPF.
-
-
-
-
-
- Close
-
-
-
- );
-}
-
-// ─────────────────────────────────────────────────────────────────────────
-// 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 (
-
-
-
-
Dashboard
-
Live operations · on-air recorders, jobs, and cluster health
-
-
-
-
- {onlineNodes}/{nodesTotal} nodes online
-
-
-
-
-
- {/* Status strip — only cells backed by a real endpoint. */}
-
-
- {armedRecorders.length} armed
- {idleRecorders.length} idle
-
- } />
- {(home?.cards?.assets?.total ?? '—')} total in library}
- />
-
- {queuedJobs.length} queued · {doneJobs.length} done
- {failedJobs.length ? · {failedJobs.length} failed : null}
-
- } />
- {offlineNodes[0].hostname || offlineNodes[0].id} offline
- : {nodesTotal ? 'all healthy' : 'no nodes registered'}
- } />
-
-
-
- {/* ───── MAIN: On air + Job queue ───── */}
-
-
navigate('recorders')}
- moreLabel="All recorders"
- live={liveRecorders.length > 0}
- />
- {liveRecorders.length > 0 ? (
-
- {[...liveRecorders, ...armedRecorders].slice(0, 6).map((r, i) => (
- navigate('recorders')} />
- ))}
-
- ) : (
- navigate('recorders')} />
- )}
-
- navigate('jobs')}
- moreLabel="All jobs"
- />
- {orderedJobs.length > 0 ? (
-
- ) : (
-
-
-
-
-
Queue clear
-
{doneJobs.length} job{doneJobs.length === 1 ? '' : 's'} completed.
-
-
-
- )}
-
-
- {/* ───── SIDE: Needs attention + Cluster ───── */}
-
- {alerts.length > 0 && (
-
-
-
-
- )}
-
-
navigate('cluster')}
- moreLabel="All nodes"
- />
- {nodesTotal > 0 ? (
-
- {clusterNodes.slice(0, 8).map(n => )}
-
- ) : (
-
-
-
-
-
No nodes registered
-
Cluster agents have not reported in.
-
-
-
- )}
-
- {/* Resources panel (live cluster GPU/CPU detail), rendered once. */}
- {window.ClusterResources && (
-
-
-
-
- )}
-
-
-
-
- {liveRecorders.length} live
- {runningJobs.length} running
- {queuedJobs.length} queued
- {failedJobs.length} failed
-
- {onlineNodes}/{nodesTotal} nodes online
- ·
-
-
-
- );
-}
-
-// ─────────────────────────────────────────────────────────────────────────
-// 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 (
-
-
- {pad(t.getHours())}:{pad(t.getMinutes())}:{pad(t.getSeconds())}
- {days[t.getDay()]}
-
- );
-}
-
-function ClockTime() {
- const t = useNow();
- const pad = n => String(n).padStart(2, '0');
- return {pad(t.getHours())}:{pad(t.getMinutes())}:{pad(t.getSeconds())} ;
-}
-
-function StatCell({ label, value, unit, foot }) {
- return (
-
-
{label}
-
{value}{unit ? {unit} : null}
-
{foot}
-
- );
-}
-
-function SectionHead({ title, sub, count, onMore, moreLabel = 'View all', live }) {
- return (
-
- {live && }
- {title}
- {count != null && {count} }
- {sub && {sub} }
- {onMore && (
- {moreLabel}
- )}
-
- );
-}
-
-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 (
-
-
- {r.live_asset_id && window.HlsPreview ? (
-
- ) : isAudio ? (
-
- ) : (
-
- )}
- {!rec &&
}
-
- {rec ? REC : ARMED }
- {(r.source || r.source_type) && {r.source || r.source_type} }
-
-
- {r.name}
- {rec && }
-
-
-
- {rec ? (r.bitrate || 'recording') : 'standby'}
- {(r.res && r.res !== '·') || r.codec ? · : null}
- {r.res && r.res !== '·' && r.res !== '—' ? r.res : (r.codec || '')}
- {r.node && r.node !== '·' && {r.node} }
-
-
- );
-}
-
-function OnAirEmpty({ sources, onStart }) {
- return (
-
-
-
-
-
Nothing on air
-
All recorders are idle. Start a source to begin capturing.
-
-
Start a recorder
-
- {sources.length > 0 && (
-
- {sources.slice(0, 4).map(s => (
-
-
- {s.name}
- {(s.source || s.source_type) && {s.source || s.source_type} }
- {s.status === 'armed' ? 'Armed' : 'Start'}
-
- ))}
-
- )}
-
- );
-}
-
-function JobQueueTable({ jobs }) {
- return (
-
-
- Job Asset Node Progress ETA
-
- {jobs.map(j => (
-
-
-
- {j.kind}
-
- {j.asset}
- {j.node}
-
- {j.status === 'running'
- ?
- : }
-
- {j.status === 'running' ? (j.eta || '—') : '—'}
-
- ))}
-
- );
-}
-
-function JobChip({ status, error }) {
- const map = { done: ['success', 'Done'], queued: ['neutral', 'Queued'], failed: ['danger', 'Failed'] };
- const [cls, label] = map[status] || ['neutral', status];
- return {label} ;
-}
-
-function AttentionRow({ a, navigate }) {
- return (
-
-
-
-
navigate(a.to)}>{a.action}
-
- );
-}
-
-function NodeRow({ n }) {
- const off = !(n.status === 'online' || n.online === true);
- const nodeId = n.hostname || n.id || n.name || 'node';
- const cpuPct = n.cpu_percent ?? n.cpu ?? n.cpu_usage ?? null;
- const memUsed = n.memory_used_gb ?? n.mem ?? (n.mem_used_mb != null ? n.mem_used_mb / 1024 : null);
- const memTotal = n.memory_total_gb ?? n.memTotal ?? n.mem_total_gb ?? null;
- const memPct = (memUsed != null && memTotal)
- ? Math.round((memUsed / memTotal) * 100)
- : (memUsed != null ? Math.min(100, Math.round((memUsed / 32) * 100)) : null);
- const gpus = n.gpus || n.devices || [];
- const gpuCount = Array.isArray(gpus) ? gpus.length : 0;
-
- return (
-
-
-
- {nodeId}
- {n.role && {n.role} }
-
- {off ? (
-
offline
- ) : (
-
- {cpuPct != null
- ?
- :
CPU ·
}
- {memPct != null
- ?
- :
RAM ·
}
-
- {gpuCount ? gpuCount + '×GPU' : '—'}
-
-
- )}
-
- );
-}
-
-function NodeMetric({ label, pct, text }) {
- const color = pct > 85 ? 'var(--danger)' : pct > 60 ? 'var(--warning)' : 'var(--accent)';
- return (
-
- {label}
-
- {text}
-
- );
-}
-
-window.Home = Home;
-window.Dashboard = Dashboard;
+}
\ No newline at end of file