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