The original first-version home page (big-button launcher with the
Dragonflight wordmark) is back at /. The Frame.io-style metrics +
recent-activity layout we've been treating as "home" is now the
Dashboard, reachable from the sidebar and from the launcher's
"Open dashboard" button.
- Renames existing Home → Dashboard (all the cards, sparklines, live
feed, job-queue, cluster mini-list are unchanged).
- New Home component: hero with the dragon-coiled-D logo (existing
img/dragon-logo.png), wordmark "DRAGONFLIGHT", a tag line, and 5
big tiles (Library, Recorders, Editor, Jobs, Settings) plus a
smaller Dashboard tile. Live cluster + recorder status pip at the
bottom mirrors what's in the topbar.
- The launcher pulls /metrics/home so the tile counts ("34 assets",
"0 live", "0 running") reflect reality.
364 lines
16 KiB
JavaScript
364 lines
16 KiB
JavaScript
// screens-home.jsx
|
|
//
|
|
// Two routes share this file:
|
|
//
|
|
// • Home — the launcher. Big-button entry into each section of the MAM.
|
|
// This is what you see when you log in / hit /. Resembles the original
|
|
// first-version landing page.
|
|
//
|
|
// • Dashboard — the operations view (recent activity, job queue, cluster,
|
|
// live recorders). Reachable from the sidebar or from the Home launcher.
|
|
// This is the React component that used to be `Home`.
|
|
|
|
function Home({ navigate }) {
|
|
// 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({});
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
const load = () => {
|
|
window.ZAMPP_API.fetch('/metrics/home?hours=1')
|
|
.then(d => { if (!cancelled) setCards(d?.cards || {}); })
|
|
.catch(() => {});
|
|
};
|
|
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: 'editor',
|
|
label: 'Editor',
|
|
icon: 'editor',
|
|
tone: 'purple',
|
|
sub: 'Beta',
|
|
desc: 'Timeline editor with cross-clip preview and render queue.',
|
|
},
|
|
{
|
|
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.',
|
|
},
|
|
{
|
|
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;
|
|
|
|
return (
|
|
<div className="launcher">
|
|
<div className="launcher-inner">
|
|
<div className="launcher-hero">
|
|
<img
|
|
className="launcher-logo"
|
|
src="img/dragon-logo.png"
|
|
alt="Dragonflight"
|
|
draggable="false"
|
|
/>
|
|
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
|
|
<p className="launcher-tagline">
|
|
Self-hosted broadcast media-asset management
|
|
</p>
|
|
</div>
|
|
|
|
<div className="launcher-grid">
|
|
{tiles.map(t => (
|
|
<button
|
|
key={t.id}
|
|
className={'launcher-tile tone-' + t.tone}
|
|
onClick={() => 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="home" 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-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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Dashboard({ navigate }) {
|
|
const { RECORDERS, JOBS, ASSETS, NODES } = window.ZAMPP_DATA;
|
|
|
|
// Live (current-state) data from the boot-time data load
|
|
const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4);
|
|
const runningJobs = JOBS.filter(j => j.status === 'running' || j.status === 'queued');
|
|
const recentAssets = [...ASSETS].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).slice(0, 6);
|
|
|
|
// Real historic sparklines from /metrics/home — buckets the last 24h.
|
|
const [metrics, setMetrics] = React.useState(null);
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
const load = () => {
|
|
window.ZAMPP_API.fetch('/metrics/home?hours=24')
|
|
.then(d => { if (!cancelled) setMetrics(d); })
|
|
.catch(() => {});
|
|
};
|
|
load();
|
|
const t = setInterval(load, 30_000);
|
|
return () => { cancelled = true; clearInterval(t); };
|
|
}, []);
|
|
|
|
const cards = metrics?.cards || {};
|
|
const vals = (s) => Array.isArray(s) ? s.map(p => p.v) : [];
|
|
|
|
// Card values come from /metrics so that "Library" reflects what's in the
|
|
// DB right now, not whatever ZAMPP_DATA happens to be cached as on first load.
|
|
const assetsTotal = cards.assets?.total ?? ASSETS.length;
|
|
const liveCount = cards.recorders?.live ?? liveRecorders.length;
|
|
const totalRecs = cards.recorders?.total ?? RECORDERS.length;
|
|
const runningCount = cards.jobs?.running ?? runningJobs.length;
|
|
const doneCount = cards.jobs?.done_total ?? JOBS.filter(j => j.status === 'done').length;
|
|
const failedCount = 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;
|
|
|
|
// Sum the most recent hour of each bucketed series for the delta line so
|
|
// the "+N this hour" hint always reflects the latest bucket.
|
|
const sumWindow = (series) => (Array.isArray(series) ? series.reduce((a, p) => a + p.v, 0) : 0);
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="home-greeting">
|
|
<h1>Dashboard</h1>
|
|
<p>
|
|
{liveCount > 0 ? liveCount + ' live · ' : ''}
|
|
{runningCount > 0 ? runningCount + ' job' + (runningCount > 1 ? 's running' : ' running') + ' · ' : ''}
|
|
{assetsTotal.toLocaleString()} assets
|
|
</p>
|
|
</div>
|
|
|
|
<div className="stat-row">
|
|
<div className="stat-card" onClick={() => navigate('library')} style={{ cursor: 'pointer' }}>
|
|
<div className="label"><Icon name="library" size={12} /> Library</div>
|
|
<div className="value">{assetsTotal.toLocaleString()}</div>
|
|
<div className="delta">
|
|
{sumWindow(cards.assets?.series) > 0
|
|
? '+' + sumWindow(cards.assets?.series) + ' added in last 24h'
|
|
: 'Total assets'}
|
|
</div>
|
|
<Sparkline data={vals(cards.assets?.series)} color="#5B7CFA" />
|
|
</div>
|
|
<div className="stat-card" onClick={() => navigate('recorders')} style={{ cursor: 'pointer' }}>
|
|
<div className="label"><Icon name="record" size={12} /> Live feeds</div>
|
|
<div className="value">{liveCount}</div>
|
|
<div className="delta">{totalRecs} recorder{totalRecs === 1 ? '' : 's'} configured</div>
|
|
<Sparkline data={vals(cards.recorders?.series)} color="#2DD4A8" />
|
|
</div>
|
|
<div className="stat-card" onClick={() => navigate('jobs')} style={{ cursor: 'pointer' }}>
|
|
<div className="label"><Icon name="jobs" size={12} /> Jobs</div>
|
|
<div className="value">{runningCount}<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 16 }}> / {doneCount} done</span></div>
|
|
<div className="delta" style={{ color: failedCount > 0 ? 'var(--warning)' : '' }}>
|
|
{failedCount > 0 ? failedCount + ' failed' : 'All clear'}
|
|
{sumWindow(cards.jobs?.series_done) > 0 && (
|
|
<> · {sumWindow(cards.jobs?.series_done)} completed in last 24h</>
|
|
)}
|
|
</div>
|
|
<Sparkline data={vals(cards.jobs?.series_done)} color="#B57CFA" />
|
|
</div>
|
|
<div className="stat-card" onClick={() => navigate('cluster')} style={{ cursor: 'pointer' }}>
|
|
<div className="label"><Icon name="hdd" size={12} /> Cluster nodes</div>
|
|
<div className="value">{nodesOnline}<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 16 }}> / {nodesTotal} online</span></div>
|
|
<div className="delta">Heartbeat within 2 min</div>
|
|
<Sparkline data={vals(cards.cluster?.series)} color="#F5A623" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="home-grid">
|
|
<div>
|
|
{liveRecorders.length > 0 && (
|
|
<>
|
|
<SectionHead title="Live now" onMore={() => navigate('recorders')} moreLabel="All recorders" />
|
|
<div className="live-feed-grid">
|
|
{liveRecorders.map(r => (
|
|
<div key={r.id} className="live-feed-tile" onClick={() => navigate('recorders')}>
|
|
<div className="live-feed-tile-badge"><span className="badge live">REC</span></div>
|
|
{r.live_asset_id
|
|
? <HlsPreview assetId={r.live_asset_id} />
|
|
: <FauxFrame />}
|
|
<div className="live-feed-tile-label">
|
|
<span className="name">{r.name}</span>
|
|
<span className="time">{r.elapsed}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div style={{ height: 16 }} />
|
|
</>
|
|
)}
|
|
|
|
<SectionHead title="Recently added" onMore={() => navigate('library')} moreLabel="All assets" />
|
|
<div className="activity-feed">
|
|
{recentAssets.length === 0 ? (
|
|
<div style={{ padding: '16px 0', color: 'var(--text-3)', fontSize: 12.5 }}>No assets yet.</div>
|
|
) : recentAssets.map(a => (
|
|
<div key={a.id} className="activity-row">
|
|
<div className="activity-icon record">
|
|
<Icon name={a.type === 'audio' ? 'audio' : 'video'} size={12} />
|
|
</div>
|
|
<div className="activity-text">
|
|
<span className="target">{a.name}</span>
|
|
{a.project && <> in <strong>{a.project}</strong></>}
|
|
</div>
|
|
<div className="activity-time">{a.updated}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<SectionHead title="Job queue" onMore={() => navigate('jobs')} moreLabel="View all" />
|
|
<div className="panel" style={{ padding: 4 }}>
|
|
{JOBS.length === 0
|
|
? <div style={{ padding: '20px 12px', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>No jobs.</div>
|
|
: JOBS.slice(0, 5).map(j => <MiniJobRow key={j.id} job={j} />)}
|
|
</div>
|
|
|
|
<div style={{ height: 16 }} />
|
|
<SectionHead title="Cluster" onMore={() => navigate('cluster')} moreLabel="View all" />
|
|
<div className="panel" style={{ padding: 4 }}>
|
|
{NODES.length === 0
|
|
? <div style={{ padding: '16px 12px', color: 'var(--text-3)', fontSize: 12.5 }}>No nodes found.</div>
|
|
: NODES.slice(0, 4).map(n => {
|
|
const nodeId = n.id || n.hostname || n.name || 'node';
|
|
const isOnline = n.status === 'online' || n.online === true;
|
|
const cpuPct = n.cpu_percent ?? n.cpu ?? n.cpu_usage ?? null;
|
|
const memRaw = n.memory_used_gb ?? n.mem ?? (n.mem_used_mb != null ? n.mem_used_mb / 1024 : null);
|
|
const memGb = memRaw != null ? Number(memRaw) : null;
|
|
return (
|
|
<div key={nodeId} style={{ padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 8, borderBottom: '1px solid var(--border)' }}>
|
|
<StatusDot status={isOnline ? 'online' : 'offline'} />
|
|
<span style={{ fontSize: 12.5, fontWeight: 500 }}>{nodeId}</span>
|
|
<span style={{ flex: 1 }} />
|
|
{cpuPct != null && <span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>CPU {Math.round(cpuPct)}%</span>}
|
|
{memGb != null && <span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{memGb < 1 ? Math.round(memGb * 1024) + 'MB' : memGb.toFixed(1) + 'GB'} RAM</span>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionHead({ title, onMore, moreLabel = 'View all' }) {
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'center', padding: '16px 0 10px', gap: 12 }}>
|
|
<div style={{ fontSize: 13, fontWeight: 600, letterSpacing: '-0.01em', whiteSpace: 'nowrap' }}>{title}</div>
|
|
{onMore && (
|
|
<button className="btn ghost sm" style={{ marginLeft: 'auto', whiteSpace: 'nowrap' }} onClick={onMore}>
|
|
{moreLabel}<Icon name="arrowRight" size={11} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MiniJobRow({ job }) {
|
|
return (
|
|
<div style={{ padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 10, borderBottom: '1px solid var(--border)' }}>
|
|
<StatusDot status={job.status} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: 12, display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
<span style={{ color: 'var(--text-2)' }}>{job.kind}</span>
|
|
<span className="muted" style={{ fontFamily: 'var(--font-mono)', fontSize: 10.5 }}>·</span>
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{job.asset}</span>
|
|
</div>
|
|
{job.status === 'running' && (
|
|
<div style={{ marginTop: 5, height: 3, background: 'var(--bg-3)', borderRadius: 99, overflow: 'hidden' }}>
|
|
<div style={{ width: job.progress + '%', height: '100%', background: 'var(--accent)', transition: 'width 300ms' }} />
|
|
</div>
|
|
)}
|
|
{job.status === 'failed' && <div style={{ marginTop: 3, fontSize: 11, color: 'var(--danger)' }}>{job.error}</div>}
|
|
</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10.5, color: 'var(--text-3)', minWidth: 40, textAlign: 'right' }}>
|
|
{job.status === 'running' ? job.eta : job.status}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
window.Home = Home;
|
|
window.Dashboard = Dashboard;
|