feat(home): restore launcher home page; move current home to Dashboard

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.
This commit is contained in:
Zac Gaetano 2026-05-23 10:48:06 -04:00
parent 7a6296585c
commit 72fc9cb755

View file

@ -1,6 +1,164 @@
// 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
@ -38,13 +196,12 @@ function Home({ navigate }) {
// 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 lastBucket = (series) => (Array.isArray(series) && series.length ? series[series.length - 1].v : 0);
const sumWindow = (series) => (Array.isArray(series) ? series.reduce((a, p) => a + p.v, 0) : 0);
return (
<div className="page">
<div className="home-greeting">
<h1>Dragonflight</h1>
<h1>Dashboard</h1>
<p>
{liveCount > 0 ? liveCount + ' live · ' : ''}
{runningCount > 0 ? runningCount + ' job' + (runningCount > 1 ? 's running' : ' running') + ' · ' : ''}
@ -204,3 +361,4 @@ function MiniJobRow({ job }) {
}
window.Home = Home;
window.Dashboard = Dashboard;