// screens-home.jsx // // Two routes share this file: // // • Home - the launcher. Big-button entry into each section of the MAM. // Untouched in this rewrite. // // • Dashboard - the operations view. Rebuilt as a control-room status // board, not a SaaS analytics page. Sections render top-down by // operator priority: // // 1. ON AIR - live recorder tiles, full-width // 2. UP NEXT - single-row strip of next scheduled recordings // 3. ATTENTION - conditional; only when something failed // 4. WORK + CLUSTER - two-column dense panels // 5. STATUS BAR - single mono-text line, bottom // // Anything that would just say "all clear" is hidden, not rendered. function Home({ navigate }) { const [showDownloads, setShowDownloads] = React.useState(false); // Pull live counts so the tile subtitles ("34 assets", "0 live", "3 running") // reflect what's actually in the DB right now, not a stale boot-time cache. const [cards, setCards] = React.useState({}); // Playout has no /metrics/home card yet (and the playout schema may not be // migrated on every install); fetch /playout/channels separately and degrade // silently — the tile just shows "No channels" if the endpoint isn't there. const [playoutChannels, setPlayoutChannels] = React.useState(null); React.useEffect(() => { let cancelled = false; const load = () => { window.ZAMPP_API.fetch('/metrics/home?hours=1') .then(d => { if (!cancelled) setCards(d?.cards || {}); }) .catch(() => {}); window.ZAMPP_API.fetch('/playout/channels') .then(d => { if (!cancelled) setPlayoutChannels(Array.isArray(d) ? d : []); }) .catch(() => { if (!cancelled) setPlayoutChannels([]); }); }; load(); const t = setInterval(load, 30_000); return () => { cancelled = true; clearInterval(t); }; }, []); const { ASSETS = [], RECORDERS = [], JOBS = [], NODES = [] } = window.ZAMPP_DATA || {}; const assetsTotal = cards.assets?.total ?? ASSETS.length; const liveCount = cards.recorders?.live ?? RECORDERS.filter(r => r.status === 'recording').length; const totalRecs = cards.recorders?.total ?? RECORDERS.length; const runningJobs = cards.jobs?.running ?? JOBS.filter(j => j.status === 'running' || j.status === 'queued').length; const failedJobs = cards.jobs?.failed_total ?? JOBS.filter(j => j.status === 'failed').length; const nodesOnline = cards.cluster?.online ?? NODES.filter(n => n.status === 'online' || n.online === true).length; const nodesTotal = cards.cluster?.total ?? NODES.length; const tiles = [ { id: 'library', label: 'Library', icon: 'library', tone: 'accent', sub: assetsTotal > 0 ? assetsTotal.toLocaleString() + ' assets' : 'No assets yet', desc: 'Browse projects, bins, and assets. Hover-scrub previews.', }, { id: 'recorders', label: 'Recorders', icon: 'record', tone: 'live', sub: liveCount > 0 ? liveCount + ' live · ' + totalRecs + ' configured' : totalRecs + ' configured', desc: 'SDI · SRT · RTMP ingest. Start, stop, schedule.', }, { id: 'playout', label: 'Playout', icon: 'signal', tone: 'accent', sub: (() => { if (playoutChannels === null) return '·'; const total = playoutChannels.length; const onAir = playoutChannels.filter(c => c.status === 'running').length; if (total === 0) return 'No channels'; if (onAir > 0) return onAir + ' on air · ' + total + ' channel' + (total === 1 ? '' : 's'); return total + ' channel' + (total === 1 ? '' : 's'); })(), desc: 'Master Control. SDI · NDI · SRT · RTMP playout, playlists, as-run.', }, { id: '__downloads', label: 'Downloads', icon: 'download', tone: 'purple', sub: 'Plugin · Teams ISO', desc: 'Download the Premiere Pro UXP plugin and the Teams ISO installer.', }, { id: 'jobs', label: 'Jobs', icon: 'jobs', tone: failedJobs > 0 ? 'warn' : 'success', sub: runningJobs > 0 ? runningJobs + ' running' + (failedJobs > 0 ? ' · ' + failedJobs + ' failed' : '') : (failedJobs > 0 ? failedJobs + ' failed' : 'All clear'), desc: 'Proxy + thumbnail queue. Retry failed jobs.', }, ]; const settingsTile = { id: 'settings', label: 'Settings', icon: 'settings', tone: 'neutral', sub: 'S3 · Encoder · Growing files', desc: 'Storage, proxy encoder, capture SDK, growing-file mode.', }; const clusterHealthy = !nodesTotal || nodesOnline >= nodesTotal; // Activity strip (#153): live recorders + last-24h assets + alerts. const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4); const recentAssets = (() => { const dayAgo = Date.now() - 86400000; return ASSETS .filter(a => a.created_at && new Date(a.created_at).getTime() > dayAgo) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) .slice(0, 6); })(); const failedCount = JOBS.filter(j => j.status === 'failed').length; const errCount = RECORDERS.filter(r => r.status === 'error').length; const hasActivity = liveRecorders.length || recentAssets.length || failedCount || errCount; return (

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