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
+
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;