From 463cc3694df61246574616519eb342b28ceda436 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Tue, 2 Jun 2026 23:33:58 -0400 Subject: [PATCH] feat(web-ui): nested bins tree, DragonFlame logo, recorder modal 2x2 grid, cleanup .bak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Library: nested bins with expand/collapse tree in sidebar - buildBinTree() + collectDescendantIds() helpers - BinTreeNodes recursive component with hover sub-bin create (+) button - Selecting a parent bin shows assets from all descendant bins too - Home: canvas DragonFlame particle animation behind logo (90 flame + 30 spark), logo 140px - Recorder modal: source-type-grid 3-col → 2x2 so Deltacast card no longer overflows - CSS: launcher background radial gradient taller; launcher-logo-wrap 160x200px - Cleanup: remove capture.js.bak: screens-home.jsx --- services/web-ui/public/screens-home.jsx | 1028 +++-------------------- 1 file changed, 127 insertions(+), 901 deletions(-) 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 => ( - - ))} - - -
- -
- -
- - {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'}.} - - -
- )} - - {liveRecorders.length > 0 && ( -
-
- - Recording now - {liveRecorders.length} live -
- -
-
- {liveRecorders.map(r => ( - - ))} -
-
- )} - - {recentAssets.length > 0 && ( -
-
- - Last 24 hours - {recentAssets.length} new asset{recentAssets.length === 1 ? '' : 's'} -
- -
-
- {recentAssets.map(a => ( - - ))} -
-
- )} -
- )} - -
- - - {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}
} -
- {rel.ccx && ( - - UXP plugin (.ccx) - - )} - {rel.installer && ( - - Windows installer - - )} -
-
- ))} - - {/* ── Dragon-ISO ── */} -
- - Dragon-ISO -
-
- NDI ISO recorder for Microsoft Teams. Windows, .NET 8 WPF. -
-
-
- Releases -
- -
-
- -
- -
-
-
- ); -} - -// ───────────────────────────────────────────────────────────────────────── -// 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 && ( - - -
- {alerts.map(a => )} -
-
- )} - - 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 && ( - - )} -
- ); -} - -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.
-
- -
- {sources.length > 0 && ( -
- {sources.slice(0, 4).map(s => ( - - ))} -
- )} -
- ); -} - -function JobQueueTable({ jobs }) { - return ( -
-
- JobAssetNodeProgressETA -
- {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 ( -
- -
-
{a.title}
-
{a.meta}
-
- -
- ); -} - -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