From 02d502baaf7bd1f15724c208d97f0632c051610d Mon Sep 17 00:00:00 2001 From: OpenCode Date: Wed, 3 Jun 2026 03:58:35 +0000 Subject: [PATCH] fix(web-ui): restore full screens-home.jsx with DragonFlame + Home + Dashboard --- services/web-ui/public/screens-home.jsx | 916 +++++++++++++++++++++++- 1 file changed, 915 insertions(+), 1 deletion(-) diff --git a/services/web-ui/public/screens-home.jsx b/services/web-ui/public/screens-home.jsx index 5a7eabf..14eeaa3 100644 --- a/services/web-ui/public/screens-home.jsx +++ b/services/web-ui/public/screens-home.jsx @@ -1,3 +1,22 @@ +// 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. + // screens-home.jsx // // Two routes share this file: @@ -136,4 +155,899 @@ function DragonFlame() { return React.createElement('span', { className: 'launcher-logo-pulse' }, React.createElement('canvas', { ref: canvasRef }) ); -} \ No newline at end of file +} + +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 ( +
+
+
+ + + Dragonflight + +

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)} />} +
+ ); +} + +// 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;