dashboard: redesign stat cards, compress header, improve density

This commit is contained in:
Zac Gaetano 2026-05-26 22:54:45 -04:00
parent 65684aa577
commit e5e0656a6a

View file

@ -200,48 +200,51 @@ function Dashboard({ navigate }) {
return ( return (
<div className="page"> <div className="page">
<div className="home-greeting"> <div className="dash-stat-row">
<h1>Dashboard</h1> <div className="dash-stat" onClick={() => navigate('library')} style={{ cursor: 'pointer' }}>
<p> <div className="dash-stat-top">
{liveCount > 0 ? liveCount + ' live · ' : ''} <span className="dash-stat-label">Library</span>
{runningCount > 0 ? runningCount + ' job' + (runningCount > 1 ? 's running' : ' running') + ' · ' : ''} <Icon name="library" size={13} className="dash-stat-ico" />
{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> </div>
<Sparkline data={vals(cards.assets?.series)} color="#5B7CFA" /> <div className="dash-stat-mono">{assetsTotal.toLocaleString()}</div>
<div className="dash-stat-sub">
{sumWindow(cards.assets?.series) > 0
? '+' + sumWindow(cards.assets?.series) + ' in 24h'
: 'total assets'}
</div>
<DashSparkline data={vals(cards.assets?.series)} color="#5B7CFA" />
</div> </div>
<div className="stat-card" onClick={() => navigate('recorders')} style={{ cursor: 'pointer' }}> <div className="dash-stat" onClick={() => navigate('recorders')} style={{ cursor: 'pointer' }}>
<div className="label"><Icon name="record" size={12} /> Live feeds</div> <div className="dash-stat-top">
<div className="value">{liveCount}</div> <span className="dash-stat-label">Live feeds</span>
<div className="delta">{totalRecs} recorder{totalRecs === 1 ? '' : 's'} configured</div> <Icon name="record" size={13} className="dash-stat-ico" />
<Sparkline data={vals(cards.recorders?.series)} color="#2DD4A8" /> </div>
<div className="dash-stat-mono">{liveCount}</div>
<div className="dash-stat-sub">{totalRecs} configured</div>
<DashSparkline data={vals(cards.recorders?.series)} color="#2DD4A8" />
</div> </div>
<div className="stat-card" onClick={() => navigate('jobs')} style={{ cursor: 'pointer' }}> <div className="dash-stat" onClick={() => navigate('jobs')} style={{ cursor: 'pointer' }}>
<div className="label"><Icon name="jobs" size={12} /> Jobs</div> <div className="dash-stat-top">
<div className="value">{runningCount}<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 16 }}> / {doneCount} done</span></div> <span className="dash-stat-label">Jobs</span>
<div className="delta" style={{ color: failedCount > 0 ? 'var(--warning)' : '' }}> <Icon name="jobs" size={13} className="dash-stat-ico" />
</div>
<div className="dash-stat-mono">{runningCount}<span className="dash-stat-mono-sub"> / {doneCount}</span></div>
<div className="dash-stat-sub" style={{ color: failedCount > 0 ? 'var(--warning)' : '' }}>
{failedCount > 0 ? failedCount + ' failed' : 'All clear'} {failedCount > 0 ? failedCount + ' failed' : 'All clear'}
{sumWindow(cards.jobs?.series_done) > 0 && ( {sumWindow(cards.jobs?.series_done) > 0 && (
<> · {sumWindow(cards.jobs?.series_done)} completed in last 24h</> <> · {sumWindow(cards.jobs?.series_done)} in 24h</>
)} )}
</div> </div>
<Sparkline data={vals(cards.jobs?.series_done)} color="#B57CFA" /> <DashSparkline data={vals(cards.jobs?.series_done)} color="#B57CFA" />
</div> </div>
<div className="stat-card" onClick={() => navigate('cluster')} style={{ cursor: 'pointer' }}> <div className="dash-stat" onClick={() => navigate('cluster')} style={{ cursor: 'pointer' }}>
<div className="label"><Icon name="hdd" size={12} /> Cluster nodes</div> <div className="dash-stat-top">
<div className="value">{nodesOnline}<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 16 }}> / {nodesTotal} online</span></div> <span className="dash-stat-label">Cluster</span>
<div className="delta">Heartbeat within 2 min</div> <Icon name="hdd" size={13} className="dash-stat-ico" />
<Sparkline data={vals(cards.cluster?.series)} color="#F5A623" /> </div>
<div className="dash-stat-mono">{nodesOnline}<span className="dash-stat-mono-sub"> / {nodesTotal}</span></div>
<div className="dash-stat-sub">nodes online</div>
<DashSparkline data={vals(cards.cluster?.series)} color="#F5A623" />
</div> </div>
</div> </div>
@ -259,8 +262,15 @@ function Dashboard({ navigate }) {
: <FauxFrame />} : <FauxFrame />}
<div className="live-feed-tile-label"> <div className="live-feed-tile-label">
<span className="name">{r.name}</span> <span className="name">{r.name}</span>
{r.source && <span className="source">{r.source}</span>}
<span className="time">{r.elapsed}</span> <span className="time">{r.elapsed}</span>
</div> </div>
{r.project && (
<div className="live-feed-tile-project">
<span className="live-feed-project-dot" style={{ background: r.project_color || 'var(--text-3)' }} />
<span>{r.project}</span>
</div>
)}
</div> </div>
))} ))}
</div> </div>
@ -271,7 +281,7 @@ function Dashboard({ navigate }) {
<SectionHead title="Recently added" onMore={() => navigate('library')} moreLabel="All assets" /> <SectionHead title="Recently added" onMore={() => navigate('library')} moreLabel="All assets" />
<div className="activity-feed"> <div className="activity-feed">
{recentAssets.length === 0 ? ( {recentAssets.length === 0 ? (
<div style={{ padding: '16px 0', color: 'var(--text-3)', fontSize: 12.5 }}>No assets yet.</div> <div className="dash-empty">No assets yet.</div>
) : recentAssets.map(a => ( ) : recentAssets.map(a => (
<div key={a.id} className="activity-row"> <div key={a.id} className="activity-row">
<div className="activity-icon record"> <div className="activity-icon record">
@ -281,6 +291,10 @@ function Dashboard({ navigate }) {
<span className="target">{a.name}</span> <span className="target">{a.name}</span>
{a.project && <> in <strong>{a.project}</strong></>} {a.project && <> in <strong>{a.project}</strong></>}
</div> </div>
<div className="activity-meta">
{a.duration && <span className="activity-dur">{a.duration}</span>}
{a.res && <span className="activity-res">{a.res}</span>}
</div>
<div className="activity-time">{a.updated}</div> <div className="activity-time">{a.updated}</div>
</div> </div>
))} ))}
@ -291,7 +305,7 @@ function Dashboard({ navigate }) {
<SectionHead title="Job queue" onMore={() => navigate('jobs')} moreLabel="View all" /> <SectionHead title="Job queue" onMore={() => navigate('jobs')} moreLabel="View all" />
<div className="panel" style={{ padding: 4 }}> <div className="panel" style={{ padding: 4 }}>
{JOBS.length === 0 {JOBS.length === 0
? <div style={{ padding: '20px 12px', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>No jobs.</div> ? <div className="dash-empty-panel">No jobs.</div>
: JOBS.slice(0, 5).map(j => <MiniJobRow key={j.id} job={j} />)} : JOBS.slice(0, 5).map(j => <MiniJobRow key={j.id} job={j} />)}
</div> </div>
@ -299,20 +313,38 @@ function Dashboard({ navigate }) {
<SectionHead title="Cluster" onMore={() => navigate('cluster')} moreLabel="View all" /> <SectionHead title="Cluster" onMore={() => navigate('cluster')} moreLabel="View all" />
<div className="panel" style={{ padding: 4 }}> <div className="panel" style={{ padding: 4 }}>
{NODES.length === 0 {NODES.length === 0
? <div style={{ padding: '16px 12px', color: 'var(--text-3)', fontSize: 12.5 }}>No nodes found.</div> ? <div className="dash-empty-panel">No nodes found.</div>
: NODES.slice(0, 4).map(n => { : NODES.slice(0, 4).map(n => {
const nodeId = n.id || n.hostname || n.name || 'node'; const nodeId = n.id || n.hostname || n.name || 'node';
const isOnline = n.status === 'online' || n.online === true; const isOnline = n.status === 'online' || n.online === true;
const cpuPct = n.cpu_percent ?? n.cpu ?? n.cpu_usage ?? null; 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 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; const memGb = memRaw != null ? Number(memRaw) : null;
const memTotal = n.memory_total_gb ?? n.mem_total_gb ?? null;
const memPct = memGb && memTotal ? Math.round((memGb / memTotal) * 100) : null;
return ( return (
<div key={nodeId} style={{ padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 8, borderBottom: '1px solid var(--border)' }}> <div key={nodeId} className="cluster-node-row">
<StatusDot status={isOnline ? 'online' : 'offline'} /> <StatusDot status={isOnline ? 'online' : 'offline'} />
<span style={{ fontSize: 12.5, fontWeight: 500 }}>{nodeId}</span> <span className="cluster-node-name">{nodeId}</span>
<span style={{ flex: 1 }} /> <span className="cluster-node-spacer" />
{cpuPct != null && <span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>CPU {Math.round(cpuPct)}%</span>} {cpuPct != null && (
{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>} <span className="cluster-node-metric">
<span className="cluster-node-metric-label">CPU</span>
<span className="cluster-node-bar">
<span className="cluster-node-bar-fill" style={{ width: Math.round(cpuPct) + '%', background: cpuPct > 80 ? 'var(--warning)' : 'var(--text-3)' }} />
</span>
<span className="cluster-node-metric-val">{Math.round(cpuPct)}%</span>
</span>
)}
{memGb != null && (
<span className="cluster-node-metric">
<span className="cluster-node-metric-label">MEM</span>
<span className="cluster-node-bar">
<span className="cluster-node-bar-fill" style={{ width: (memPct || Math.round((memGb / 32) * 100)) + '%', background: (memPct || 0) > 85 ? 'var(--warning)' : 'var(--text-3)' }} />
</span>
<span className="cluster-node-metric-val">{memGb < 1 ? Math.round(memGb * 1024) + 'MB' : memGb.toFixed(1) + 'GB'}</span>
</span>
)}
</div> </div>
); );
})} })}
@ -323,6 +355,26 @@ function Dashboard({ navigate }) {
); );
} }
function DashSparkline({ data, color }) {
if (!data || data.length < 2) return <div className="dash-sparkline" />;
const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;
const h = 24;
const w = 80;
const step = w / (data.length - 1);
const pts = data.map((d, i) => (i * step) + ',' + (h - ((d - min) / range) * h)).join(' ');
const area = '0,' + h + ' ' + pts + ' ' + w + ',' + h;
return (
<div className="dash-sparkline">
<svg viewBox={'0 0 ' + w + ' ' + h} preserveAspectRatio="none" style={{ width: '100%', height: h, display: 'block' }}>
<polygon points={area} fill={color} opacity="0.12" />
<polyline points={pts} fill="none" stroke={color} strokeWidth="1.2" />
</svg>
</div>
);
}
function SectionHead({ title, onMore, moreLabel = 'View all' }) { function SectionHead({ title, onMore, moreLabel = 'View all' }) {
return ( return (
<div style={{ display: 'flex', alignItems: 'center', padding: '16px 0 10px', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', padding: '16px 0 10px', gap: 12 }}>
@ -338,23 +390,25 @@ function SectionHead({ title, onMore, moreLabel = 'View all' }) {
function MiniJobRow({ job }) { function MiniJobRow({ job }) {
return ( return (
<div style={{ padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 10, borderBottom: '1px solid var(--border)' }}> <div className="mini-job-row">
<StatusDot status={job.status} /> <StatusDot status={job.status} />
<div style={{ flex: 1, minWidth: 0 }}> <div className="mini-job-body">
<div style={{ fontSize: 12, display: 'flex', gap: 6, alignItems: 'center' }}> <div className="mini-job-line1">
<span style={{ color: 'var(--text-2)' }}>{job.kind}</span> <span className="mini-job-kind">{job.kind}</span>
<span className="muted" style={{ fontFamily: 'var(--font-mono)', fontSize: 10.5 }}>·</span> <span className="mini-job-sep">·</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{job.asset}</span> <span className="mini-job-asset">{job.asset}</span>
{job.node && <span className="mini-job-node">on {job.node}</span>}
</div> </div>
{job.status === 'running' && ( {job.status === 'running' && (
<div style={{ marginTop: 5, height: 3, background: 'var(--bg-3)', borderRadius: 99, overflow: 'hidden' }}> <div className="mini-job-progress">
<div style={{ width: job.progress + '%', height: '100%', background: 'var(--accent)', transition: 'width 300ms' }} /> <div className="mini-job-progress-fill" style={{ width: (job.progress || 0) + '%' }} />
<span className="mini-job-pct">{job.progress || 0}%</span>
</div> </div>
)} )}
{job.status === 'failed' && <div style={{ marginTop: 3, fontSize: 11, color: 'var(--danger)' }}>{job.error}</div>} {job.status === 'failed' && <div className="mini-job-error">{job.error}</div>}
</div> </div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10.5, color: 'var(--text-3)', minWidth: 40, textAlign: 'right' }}> <div className="mini-job-right">
{job.status === 'running' ? job.eta : job.status} {job.status === 'running' && job.eta ? job.eta : <span className={'badge ' + job.status}>{job.status}</span>}
</div> </div>
</div> </div>
); );