fix(web-ui): restore full screens-home.jsx with DragonFlame + Home + Dashboard
This commit is contained in:
parent
00a7af7c54
commit
02d502baaf
1 changed files with 915 additions and 1 deletions
|
|
@ -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 })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="launcher">
|
||||
<div className="launcher-inner">
|
||||
<div className="launcher-hero">
|
||||
<span className="launcher-logo-wrap">
|
||||
<DragonFlame />
|
||||
<img
|
||||
className="launcher-logo"
|
||||
src="img/dragon-logo.png"
|
||||
alt="Dragonflight"
|
||||
draggable="false"
|
||||
/>
|
||||
</span>
|
||||
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
|
||||
<p className="launcher-kicker">Let's Create</p>
|
||||
<p className="launcher-tagline">
|
||||
Media Asset Management & Production Platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="launcher-grid">
|
||||
{tiles.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={'launcher-tile tone-' + t.tone}
|
||||
onClick={() => t.id === '__downloads' ? setShowDownloads(true) : navigate(t.id)}
|
||||
>
|
||||
<span className="launcher-tile-icon">
|
||||
<Icon name={t.icon} size={26} />
|
||||
</span>
|
||||
<span className="launcher-tile-label">{t.label}</span>
|
||||
<span className="launcher-tile-sub">{t.sub}</span>
|
||||
<span className="launcher-tile-desc">{t.desc}</span>
|
||||
<span className="launcher-tile-arrow">
|
||||
<Icon name="arrowRight" size={14} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="launcher-tile tone-ghost launcher-tile-secondary"
|
||||
onClick={() => navigate('dashboard')}
|
||||
>
|
||||
<span className="launcher-tile-icon">
|
||||
<Icon name="layout" size={22} />
|
||||
</span>
|
||||
<span className="launcher-tile-label">Dashboard</span>
|
||||
<span className="launcher-tile-sub">Operations view</span>
|
||||
<span className="launcher-tile-desc">
|
||||
Recent activity, job queue, cluster health.
|
||||
</span>
|
||||
<span className="launcher-tile-arrow">
|
||||
<Icon name="arrowRight" size={14} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="launcher-settings-row">
|
||||
<button
|
||||
className={'launcher-tile tone-' + settingsTile.tone + ' launcher-tile-settings'}
|
||||
onClick={() => navigate(settingsTile.id)}
|
||||
>
|
||||
<span className="launcher-tile-icon">
|
||||
<Icon name={settingsTile.icon} size={26} />
|
||||
</span>
|
||||
<span className="launcher-tile-label">{settingsTile.label}</span>
|
||||
<span className="launcher-tile-sub">{settingsTile.sub}</span>
|
||||
<span className="launcher-tile-desc">{settingsTile.desc}</span>
|
||||
<span className="launcher-tile-arrow">
|
||||
<Icon name="arrowRight" size={14} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasActivity && (
|
||||
<div className="launcher-activity">
|
||||
{(failedCount > 0 || errCount > 0) && (
|
||||
<div className="launcher-activity-strip alert">
|
||||
<Icon name="alert" size={14} />
|
||||
<span>
|
||||
{errCount > 0 && <strong>{errCount} recorder{errCount === 1 ? '' : 's'} in error.</strong>}
|
||||
{errCount > 0 && failedCount > 0 && ' '}
|
||||
{failedCount > 0 && <strong>{failedCount} failed job{failedCount === 1 ? '' : 's'}.</strong>}
|
||||
</span>
|
||||
<button className="btn ghost sm" onClick={() => navigate('dashboard')}>Open Dashboard</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{liveRecorders.length > 0 && (
|
||||
<div className="launcher-activity-section">
|
||||
<div className="launcher-activity-head">
|
||||
<span className="rec-dot" />
|
||||
Recording now
|
||||
<span className="muted">{liveRecorders.length} live</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn ghost sm" onClick={() => navigate('monitors')}>Monitors</button>
|
||||
</div>
|
||||
<div className="launcher-activity-grid">
|
||||
{liveRecorders.map(r => (
|
||||
<button key={r.id} className="launcher-activity-item" onClick={() => navigate('recorders')}>
|
||||
<span className="badge live">REC</span>
|
||||
<span className="launcher-activity-item-name">{r.name}</span>
|
||||
<span className="launcher-activity-item-meta mono">{r.source_type || 'sdi'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recentAssets.length > 0 && (
|
||||
<div className="launcher-activity-section">
|
||||
<div className="launcher-activity-head">
|
||||
<Icon name="library" size={12} />
|
||||
Last 24 hours
|
||||
<span className="muted">{recentAssets.length} new asset{recentAssets.length === 1 ? '' : 's'}</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn ghost sm" onClick={() => navigate('library')}>Library</button>
|
||||
</div>
|
||||
<div className="launcher-activity-grid">
|
||||
{recentAssets.map(a => (
|
||||
<button key={a.id} className="launcher-activity-item" onClick={() => navigate('library')}>
|
||||
<Icon name={a.media_type === 'audio' ? 'audio' : 'video'} size={13} />
|
||||
<span className="launcher-activity-item-name">{a.display_name || a.filename || 'untitled'}</span>
|
||||
<span className="launcher-activity-item-meta mono">
|
||||
{(() => {
|
||||
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';
|
||||
})()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="launcher-status">
|
||||
<span className="launcher-status-pip">
|
||||
<span
|
||||
className="dot"
|
||||
style={{ background: clusterHealthy ? 'var(--success)' : 'var(--warning)' }}
|
||||
/>
|
||||
{clusterHealthy ? 'cluster healthy' : 'cluster degraded'}
|
||||
<span className="muted">· {nodesOnline}/{nodesTotal || 0} node{nodesTotal === 1 ? '' : 's'}</span>
|
||||
</span>
|
||||
{liveCount > 0 && (
|
||||
<span className="launcher-status-pip live">
|
||||
<span className="dot" style={{ background: 'var(--live)' }} />
|
||||
{liveCount} recorder{liveCount === 1 ? '' : 's'} live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="launcher-footer">Created by Wild Dragon LLC</div>
|
||||
</div>
|
||||
{showDownloads && <DownloadsModal onClose={() => setShowDownloads(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 580 }}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600 }}>Downloads</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="premiere-release">
|
||||
<div className="premiere-release-head">
|
||||
<span className="premiere-release-version mono">Teams ISO</span>
|
||||
{teamsIso.version && (
|
||||
<span className="premiere-release-date mono">v{teamsIso.version}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="premiere-release-notes">
|
||||
Windows installer for the Teams ISO workstation build.
|
||||
</div>
|
||||
<div className="premiere-release-actions">
|
||||
{teamsIso.available && teamsIso.url ? (
|
||||
<a href={teamsIso.url} download className="btn primary sm">
|
||||
<Icon name="download" />Teams ISO (.exe)
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<span className="btn primary sm" aria-disabled="true" style={{ opacity: 0.5, pointerEvents: 'none' }}>
|
||||
<Icon name="download" />Teams ISO (.exe)
|
||||
</span>
|
||||
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>coming soon, file pending</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{releases.length === 0 && (
|
||||
<div style={{ padding: '12px 0', color: 'var(--text-3)', fontSize: 12 }}>
|
||||
No releases registered. Upload one from Settings → Capture SDKs.
|
||||
</div>
|
||||
)}
|
||||
{releases.map((rel, i) => (
|
||||
<div key={rel.version || i} className="premiere-release">
|
||||
<div className="premiere-release-head">
|
||||
<span className="premiere-release-version mono">v{rel.version}</span>
|
||||
{latest && latest.version === rel.version && (
|
||||
<span className="badge accent">LATEST</span>
|
||||
)}
|
||||
{rel.released_at && (
|
||||
<span className="premiere-release-date mono">
|
||||
{new Date(rel.released_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{rel.notes && <div className="premiere-release-notes">{rel.notes}</div>}
|
||||
<div className="premiere-release-actions">
|
||||
{rel.ccx && (
|
||||
<a href={rel.ccx} download className="btn primary sm">
|
||||
<Icon name="download" size={12} />UXP plugin (.ccx)
|
||||
</a>
|
||||
)}
|
||||
{rel.installer && (
|
||||
<a href={rel.installer} download className="btn ghost sm">
|
||||
<Icon name="download" size={12} />Windows installer
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Dragon-ISO ── */}
|
||||
<div className="downloads-section-head" style={{ marginTop: 20 }}>
|
||||
<Icon name="film" size={13} />
|
||||
<span>Dragon-ISO</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
|
||||
NDI ISO recorder for Microsoft Teams. Windows, .NET 8 WPF.
|
||||
</div>
|
||||
<div className="premiere-release">
|
||||
<div className="premiere-release-head">
|
||||
<span className="premiere-release-version mono">Releases</span>
|
||||
</div>
|
||||
<div className="premiere-release-actions">
|
||||
<a href={DRAGON_ISO_RELEASES_URL} target="_blank" rel="noopener noreferrer" className="btn primary sm">
|
||||
<Icon name="download" size={12} />View releases on Forgejo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-foot">
|
||||
<button className="btn ghost" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 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 (
|
||||
<div className="page dashboard">
|
||||
<div className="ops-header">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<div className="ops-sub">Live operations · on-air recorders, jobs, and cluster health</div>
|
||||
</div>
|
||||
<div className="ops-header-right">
|
||||
<div className="ops-nodes-pill">
|
||||
<StatusDot status={offlineNodes.length ? 'processing' : 'online'} />
|
||||
{onlineNodes}/{nodesTotal} nodes online
|
||||
</div>
|
||||
<Clock />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status strip — only cells backed by a real endpoint. */}
|
||||
<div className="ops-stats">
|
||||
<StatCell label="On air" value={liveRecorders.length} unit="live" foot={
|
||||
<span className="stat-pips">
|
||||
<span className={'stat-pip armed ' + (armedRecorders.length ? '' : 'zero')}><i />{armedRecorders.length} armed</span>
|
||||
<span className={'stat-pip idle ' + (idleRecorders.length ? '' : 'zero')}><i />{idleRecorders.length} idle</span>
|
||||
</span>
|
||||
} />
|
||||
<StatCell
|
||||
label="Assets · 24h"
|
||||
value={assets24h == null ? '—' : assets24h}
|
||||
foot={<span>{(home?.cards?.assets?.total ?? '—')} total in library</span>}
|
||||
/>
|
||||
<StatCell label="Jobs" value={runningJobs.length} unit="active" foot={
|
||||
<span>
|
||||
{queuedJobs.length} queued · {doneJobs.length} done
|
||||
{failedJobs.length ? <span className="foot-danger"> · {failedJobs.length} failed</span> : null}
|
||||
</span>
|
||||
} />
|
||||
<StatCell label="Cluster" value={nodesTotal ? onlineNodes + '/' + nodesTotal : '—'} unit={nodesTotal ? 'nodes' : ''} foot={
|
||||
offlineNodes.length
|
||||
? <span className="foot-warn">{offlineNodes[0].hostname || offlineNodes[0].id} offline</span>
|
||||
: <span>{nodesTotal ? 'all healthy' : 'no nodes registered'}</span>
|
||||
} />
|
||||
</div>
|
||||
|
||||
<div className="dash-grid">
|
||||
{/* ───── MAIN: On air + Job queue ───── */}
|
||||
<div className="dash-main">
|
||||
<SectionHead
|
||||
title="On air"
|
||||
sub={liveRecorders.length ? liveRecorders.length + ' recording' : 'standby'}
|
||||
onMore={() => navigate('recorders')}
|
||||
moreLabel="All recorders"
|
||||
live={liveRecorders.length > 0}
|
||||
/>
|
||||
{liveRecorders.length > 0 ? (
|
||||
<div className="live-now-grid">
|
||||
{[...liveRecorders, ...armedRecorders].slice(0, 6).map((r, i) => (
|
||||
<IngestTile key={r.id} r={r} seed={i + 1} onClick={() => navigate('recorders')} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<OnAirEmpty sources={standbySources} onStart={() => navigate('recorders')} />
|
||||
)}
|
||||
|
||||
<SectionHead
|
||||
title="Job queue"
|
||||
sub={runningJobs.length + ' active · ' + queuedJobs.length + ' queued'}
|
||||
onMore={() => navigate('jobs')}
|
||||
moreLabel="All jobs"
|
||||
/>
|
||||
{orderedJobs.length > 0 ? (
|
||||
<JobQueueTable jobs={orderedJobs} />
|
||||
) : (
|
||||
<div className="onair-empty">
|
||||
<div className="onair-empty-head">
|
||||
<span className="onair-empty-icon"><Icon name="check" size={16} /></span>
|
||||
<div className="onair-empty-copy">
|
||||
<div className="onair-empty-title">Queue clear</div>
|
||||
<div className="onair-empty-sub">{doneJobs.length} job{doneJobs.length === 1 ? '' : 's'} completed.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ───── SIDE: Needs attention + Cluster ───── */}
|
||||
<div className="dash-side">
|
||||
{alerts.length > 0 && (
|
||||
<React.Fragment>
|
||||
<SectionHead title="Needs attention" count={attentionCount} />
|
||||
<div className="attention-panel">
|
||||
{alerts.map(a => <AttentionRow key={a.key} a={a} navigate={navigate} />)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<SectionHead
|
||||
title="Cluster"
|
||||
sub={nodesTotal ? onlineNodes + '/' + nodesTotal + ' online' : 'no nodes'}
|
||||
onMore={() => navigate('cluster')}
|
||||
moreLabel="All nodes"
|
||||
/>
|
||||
{nodesTotal > 0 ? (
|
||||
<div className="node-list">
|
||||
{clusterNodes.slice(0, 8).map(n => <NodeRow key={n.id || n.hostname || n.name} n={n} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="onair-empty">
|
||||
<div className="onair-empty-head">
|
||||
<span className="onair-empty-icon"><Icon name="alert" size={16} /></span>
|
||||
<div className="onair-empty-copy">
|
||||
<div className="onair-empty-title">No nodes registered</div>
|
||||
<div className="onair-empty-sub">Cluster agents have not reported in.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resources panel (live cluster GPU/CPU detail), rendered once. */}
|
||||
{window.ClusterResources && (
|
||||
<React.Fragment>
|
||||
<SectionHead title="Resources" />
|
||||
<window.ClusterResources />
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dash-statusbar">
|
||||
<span className="sb-item"><i className="sb-dot live" />{liveRecorders.length} live</span>
|
||||
<span className="sb-item"><i className="sb-dot run" />{runningJobs.length} running</span>
|
||||
<span className="sb-item"><i className="sb-dot" />{queuedJobs.length} queued</span>
|
||||
<span className="sb-item"><i className={'sb-dot ' + (failedJobs.length ? 'fail' : '')} />{failedJobs.length} failed</span>
|
||||
<span className="sb-spacer" />
|
||||
<span className="sb-item">{onlineNodes}/{nodesTotal} nodes online</span>
|
||||
<span className="sb-sep">·</span>
|
||||
<span className="sb-item mono"><ClockTime /></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 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 (
|
||||
<div className="ops-clock" title="System time">
|
||||
<span className="ops-clock-dot" />
|
||||
<span className="ops-clock-time mono">{pad(t.getHours())}:{pad(t.getMinutes())}:{pad(t.getSeconds())}</span>
|
||||
<span className="ops-clock-day">{days[t.getDay()]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClockTime() {
|
||||
const t = useNow();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return <span>{pad(t.getHours())}:{pad(t.getMinutes())}:{pad(t.getSeconds())}</span>;
|
||||
}
|
||||
|
||||
function StatCell({ label, value, unit, foot }) {
|
||||
return (
|
||||
<div className="stat-cell">
|
||||
<div className="stat-cell-label">{label}</div>
|
||||
<div className="stat-cell-value">{value}{unit ? <span className="stat-cell-unit">{unit}</span> : null}</div>
|
||||
<div className="stat-cell-foot">{foot}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title, sub, count, onMore, moreLabel = 'View all', live }) {
|
||||
return (
|
||||
<div className="section-head">
|
||||
{live && <span className="section-head-live" />}
|
||||
<span className="section-head-title">{title}</span>
|
||||
{count != null && <span className="section-head-count">{count}</span>}
|
||||
{sub && <span className="section-head-sub">{sub}</span>}
|
||||
{onMore && (
|
||||
<button className="btn ghost sm" onClick={onMore}>{moreLabel}<Icon name="arrowRight" size={11} /></button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={'ingest-tile ' + r.status} onClick={onClick}>
|
||||
<div className="ingest-tile-screen">
|
||||
{r.live_asset_id && window.HlsPreview ? (
|
||||
<window.HlsPreview assetId={r.live_asset_id} recorderId={r.id} />
|
||||
) : isAudio ? (
|
||||
<div className="ingest-tile-audio"><Waveform seed={seed * 5} color="var(--accent)" /></div>
|
||||
) : (
|
||||
<React.Fragment><FauxFrame seed={seed} /><div className="scanlines" /></React.Fragment>
|
||||
)}
|
||||
{!rec && <div className="ingest-tile-veil" />}
|
||||
<div className="ingest-tile-top">
|
||||
{rec ? <span className="badge live">REC</span> : <span className="badge accent">ARMED</span>}
|
||||
{(r.source || r.source_type) && <span className="badge outline">{r.source || r.source_type}</span>}
|
||||
</div>
|
||||
<div className="ingest-tile-bottom">
|
||||
<span className="ingest-tile-name">{r.name}</span>
|
||||
{rec && <span className="ingest-tile-tc mono"><Elapsed seconds={elapsed} live /></span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ingest-tile-foot">
|
||||
<span className="mono">{rec ? (r.bitrate || 'recording') : 'standby'}</span>
|
||||
{(r.res && r.res !== '·') || r.codec ? <span className="dot-sep">·</span> : null}
|
||||
<span>{r.res && r.res !== '·' && r.res !== '—' ? r.res : (r.codec || '')}</span>
|
||||
{r.node && r.node !== '·' && <span className="ingest-tile-node mono">{r.node}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OnAirEmpty({ sources, onStart }) {
|
||||
return (
|
||||
<div className="onair-empty">
|
||||
<div className="onair-empty-head">
|
||||
<span className="onair-empty-icon"><Icon name="record" size={18} /></span>
|
||||
<div className="onair-empty-copy">
|
||||
<div className="onair-empty-title">Nothing on air</div>
|
||||
<div className="onair-empty-sub">All recorders are idle. Start a source to begin capturing.</div>
|
||||
</div>
|
||||
<button className="btn primary" onClick={onStart}><Icon name="plus" size={14} />Start a recorder</button>
|
||||
</div>
|
||||
{sources.length > 0 && (
|
||||
<div className="onair-sources">
|
||||
{sources.slice(0, 4).map(s => (
|
||||
<button key={s.id} className="onair-source" onClick={onStart}>
|
||||
<StatusDot status={s.status === 'armed' ? 'armed' : 'idle'} />
|
||||
<span className="onair-source-name">{s.name}</span>
|
||||
{(s.source || s.source_type) && <span className="onair-source-src">{s.source || s.source_type}</span>}
|
||||
<span className="onair-source-go">{s.status === 'armed' ? 'Armed' : 'Start'}<Icon name="arrowRight" size={11} /></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobQueueTable({ jobs }) {
|
||||
return (
|
||||
<div className="job-table">
|
||||
<div className="job-table-head">
|
||||
<span>Job</span><span>Asset</span><span>Node</span><span>Progress</span><span>ETA</span>
|
||||
</div>
|
||||
{jobs.map(j => (
|
||||
<div key={j.id} className="job-table-row">
|
||||
<span className="jt-job">
|
||||
<StatusDot status={j.status} />
|
||||
<span className="mono">{j.kind}</span>
|
||||
</span>
|
||||
<span className="jt-asset" title={j.asset}>{j.asset}</span>
|
||||
<span className="jt-node mono">{j.node}</span>
|
||||
<span className="jt-progress">
|
||||
{j.status === 'running'
|
||||
? <span className="jt-bar"><span style={{ width: Math.round(j.progress || 0) + '%' }} /></span>
|
||||
: <JobChip status={j.status} error={j.error} />}
|
||||
</span>
|
||||
<span className="jt-eta mono">{j.status === 'running' ? (j.eta || '—') : '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobChip({ status, error }) {
|
||||
const map = { done: ['success', 'Done'], queued: ['neutral', 'Queued'], failed: ['danger', 'Failed'] };
|
||||
const [cls, label] = map[status] || ['neutral', status];
|
||||
return <span className={'badge ' + cls} title={error || undefined}>{label}</span>;
|
||||
}
|
||||
|
||||
function AttentionRow({ a, navigate }) {
|
||||
return (
|
||||
<div className="attn-row">
|
||||
<span className={'attn-sev ' + a.sev}><Icon name="alert" size={13} /></span>
|
||||
<div className="attn-body">
|
||||
<div className="attn-title">{a.title}</div>
|
||||
<div className="attn-meta mono">{a.meta}</div>
|
||||
</div>
|
||||
<button className="btn subtle sm" onClick={() => navigate(a.to)}>{a.action}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={'node-row ' + (off ? 'offline' : '')}>
|
||||
<div className="node-row-id">
|
||||
<StatusDot status={off ? 'offline' : 'online'} />
|
||||
<span className="node-name mono">{nodeId}</span>
|
||||
{n.role && <span className={'badge ' + (n.role === 'primary' ? 'accent' : 'neutral') + ' node-role'}>{n.role}</span>}
|
||||
</div>
|
||||
{off ? (
|
||||
<div className="node-row-off">offline</div>
|
||||
) : (
|
||||
<div className="node-row-metrics">
|
||||
{cpuPct != null
|
||||
? <NodeMetric label="CPU" pct={Math.round(cpuPct)} text={Math.round(cpuPct) + '%'} />
|
||||
: <div className="node-metric"><span className="node-metric-label">CPU</span><span className="node-metric-text mono">·</span></div>}
|
||||
{memPct != null
|
||||
? <NodeMetric label="RAM" pct={memPct} text={memUsed != null ? (memUsed < 1 ? Math.round(memUsed * 1024) + 'M' : memUsed.toFixed(1) + 'G') : memPct + '%'} />
|
||||
: <div className="node-metric"><span className="node-metric-label">RAM</span><span className="node-metric-text mono">·</span></div>}
|
||||
<div className="node-gpu mono" title={(Array.isArray(gpus) ? gpus.join(', ') : '') || 'no GPU'}>
|
||||
{gpuCount ? gpuCount + '×GPU' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeMetric({ label, pct, text }) {
|
||||
const color = pct > 85 ? 'var(--danger)' : pct > 60 ? 'var(--warning)' : 'var(--accent)';
|
||||
return (
|
||||
<div className="node-metric">
|
||||
<span className="node-metric-label">{label}</span>
|
||||
<span className="node-metric-bar"><span style={{ width: Math.max(2, pct) + '%', background: color }} /></span>
|
||||
<span className="node-metric-text mono">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.Home = Home;
|
||||
window.Dashboard = Dashboard;
|
||||
|
|
|
|||
Loading…
Reference in a new issue