dashboard: redesign stat cards, compress header, improve density
This commit is contained in:
parent
65684aa577
commit
e5e0656a6a
1 changed files with 107 additions and 53 deletions
|
|
@ -200,48 +200,51 @@ function Dashboard({ navigate }) {
|
|||
|
||||
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 className="dash-stat-row">
|
||||
<div className="dash-stat" onClick={() => navigate('library')} style={{ cursor: 'pointer' }}>
|
||||
<div className="dash-stat-top">
|
||||
<span className="dash-stat-label">Library</span>
|
||||
<Icon name="library" size={13} className="dash-stat-ico" />
|
||||
</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">
|
||||
<div className="dash-stat-mono">{assetsTotal.toLocaleString()}</div>
|
||||
<div className="dash-stat-sub">
|
||||
{sumWindow(cards.assets?.series) > 0
|
||||
? '+' + sumWindow(cards.assets?.series) + ' added in last 24h'
|
||||
: 'Total assets'}
|
||||
? '+' + sumWindow(cards.assets?.series) + ' in 24h'
|
||||
: 'total assets'}
|
||||
</div>
|
||||
<Sparkline data={vals(cards.assets?.series)} color="#5B7CFA" />
|
||||
<DashSparkline 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 className="dash-stat" onClick={() => navigate('recorders')} style={{ cursor: 'pointer' }}>
|
||||
<div className="dash-stat-top">
|
||||
<span className="dash-stat-label">Live feeds</span>
|
||||
<Icon name="record" size={13} className="dash-stat-ico" />
|
||||
</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)' : '' }}>
|
||||
<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 className="dash-stat" onClick={() => navigate('jobs')} style={{ cursor: 'pointer' }}>
|
||||
<div className="dash-stat-top">
|
||||
<span className="dash-stat-label">Jobs</span>
|
||||
<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'}
|
||||
{sumWindow(cards.jobs?.series_done) > 0 && (
|
||||
<> · {sumWindow(cards.jobs?.series_done)} completed in last 24h</>
|
||||
<> · {sumWindow(cards.jobs?.series_done)} in 24h</>
|
||||
)}
|
||||
</div>
|
||||
<Sparkline data={vals(cards.jobs?.series_done)} color="#B57CFA" />
|
||||
<DashSparkline 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 className="dash-stat" onClick={() => navigate('cluster')} style={{ cursor: 'pointer' }}>
|
||||
<div className="dash-stat-top">
|
||||
<span className="dash-stat-label">Cluster</span>
|
||||
<Icon name="hdd" size={13} className="dash-stat-ico" />
|
||||
</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>
|
||||
|
||||
|
|
@ -259,8 +262,15 @@ function Dashboard({ navigate }) {
|
|||
: <FauxFrame />}
|
||||
<div className="live-feed-tile-label">
|
||||
<span className="name">{r.name}</span>
|
||||
{r.source && <span className="source">{r.source}</span>}
|
||||
<span className="time">{r.elapsed}</span>
|
||||
</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>
|
||||
|
|
@ -271,7 +281,7 @@ function Dashboard({ navigate }) {
|
|||
<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>
|
||||
<div className="dash-empty">No assets yet.</div>
|
||||
) : recentAssets.map(a => (
|
||||
<div key={a.id} className="activity-row">
|
||||
<div className="activity-icon record">
|
||||
|
|
@ -281,6 +291,10 @@ function Dashboard({ navigate }) {
|
|||
<span className="target">{a.name}</span>
|
||||
{a.project && <> in <strong>{a.project}</strong></>}
|
||||
</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>
|
||||
))}
|
||||
|
|
@ -291,7 +305,7 @@ function Dashboard({ navigate }) {
|
|||
<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>
|
||||
? <div className="dash-empty-panel">No jobs.</div>
|
||||
: JOBS.slice(0, 5).map(j => <MiniJobRow key={j.id} job={j} />)}
|
||||
</div>
|
||||
|
||||
|
|
@ -299,20 +313,38 @@ function Dashboard({ navigate }) {
|
|||
<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>
|
||||
? <div className="dash-empty-panel">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;
|
||||
const memTotal = n.memory_total_gb ?? n.mem_total_gb ?? null;
|
||||
const memPct = memGb && memTotal ? Math.round((memGb / memTotal) * 100) : null;
|
||||
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'} />
|
||||
<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>}
|
||||
<span className="cluster-node-name">{nodeId}</span>
|
||||
<span className="cluster-node-spacer" />
|
||||
{cpuPct != null && (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
|
@ -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' }) {
|
||||
return (
|
||||
<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 }) {
|
||||
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} />
|
||||
<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 className="mini-job-body">
|
||||
<div className="mini-job-line1">
|
||||
<span className="mini-job-kind">{job.kind}</span>
|
||||
<span className="mini-job-sep">·</span>
|
||||
<span className="mini-job-asset">{job.asset}</span>
|
||||
{job.node && <span className="mini-job-node">on {job.node}</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 className="mini-job-progress">
|
||||
<div className="mini-job-progress-fill" style={{ width: (job.progress || 0) + '%' }} />
|
||||
<span className="mini-job-pct">{job.progress || 0}%</span>
|
||||
</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 style={{ fontFamily: 'var(--font-mono)', fontSize: 10.5, color: 'var(--text-3)', minWidth: 40, textAlign: 'right' }}>
|
||||
{job.status === 'running' ? job.eta : job.status}
|
||||
<div className="mini-job-right">
|
||||
{job.status === 'running' && job.eta ? job.eta : <span className={'badge ' + job.status}>{job.status}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue