feat(ui): wire screens to live API data; add thumbnail lazy-loading: screens-home.jsx
This commit is contained in:
parent
3574ae8a43
commit
07af51b05c
1 changed files with 87 additions and 110 deletions
|
|
@ -1,97 +1,115 @@
|
|||
// screens-home.jsx - home dashboard
|
||||
|
||||
const { ACTIVITY, RECORDERS, JOBS, ASSETS } = window.ZAMPP_DATA;
|
||||
// screens-home.jsx
|
||||
|
||||
function Home({ navigate }) {
|
||||
const sparkAssets = [12, 14, 13, 18, 16, 22, 25, 24, 28, 30, 32, 34, 38];
|
||||
const sparkIngest = [4, 6, 5, 8, 9, 12, 10, 14, 13, 18, 20, 22, 24];
|
||||
const sparkJobs = [8, 7, 10, 9, 14, 12, 16, 13, 18, 15, 12, 10, 11];
|
||||
const sparkStorage = [42, 44, 46, 47, 48, 50, 52, 54, 56, 58, 60, 62, 64];
|
||||
const { RECORDERS, JOBS, ASSETS, NODES } = window.ZAMPP_DATA;
|
||||
|
||||
const liveRecorders = RECORDERS.filter(r => r.status === "recording").slice(0, 4);
|
||||
const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4);
|
||||
const runningJobs = JOBS.filter(j => j.status === 'running' || j.status === 'queued');
|
||||
const failedJobs = JOBS.filter(j => j.status === 'failed').length;
|
||||
const recentAssets = [...ASSETS].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).slice(0, 6);
|
||||
|
||||
const spark = (n, base = 10) => Array.from({ length: 13 }, (_, i) => base + Math.round(Math.sin(i * 0.7 + n) * base * 0.3));
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="home-greeting">
|
||||
<h1>Good evening, Zach</h1>
|
||||
<p>4 recorders live · 3 jobs running · cluster healthy across 4 nodes</p>
|
||||
<h1>Dragonflight</h1>
|
||||
<p>
|
||||
{liveRecorders.length > 0 ? liveRecorders.length + ' live · ' : ''}
|
||||
{runningJobs.length > 0 ? runningJobs.length + ' job' + (runningJobs.length > 1 ? 's running' : ' running') + ' · ' : ''}
|
||||
{ASSETS.length.toLocaleString()} assets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="stat-row">
|
||||
<div className="stat-card">
|
||||
<div className="label"><Icon name="library" size={12} /> Library</div>
|
||||
<div className="value">1,553</div>
|
||||
<div className="delta up">+38 today</div>
|
||||
<Sparkline data={sparkAssets} color="#5B7CFA" />
|
||||
<div className="value">{ASSETS.length.toLocaleString()}</div>
|
||||
<div className="delta">Total assets</div>
|
||||
<Sparkline data={spark(1, ASSETS.length || 10)} color="#5B7CFA" />
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="label"><Icon name="upload" size={12} /> Ingest (24h)</div>
|
||||
<div className="value">412 GB</div>
|
||||
<div className="delta up">+82 GB last hr</div>
|
||||
<Sparkline data={sparkIngest} color="#2DD4A8" />
|
||||
<div className="label"><Icon name="record" size={12} /> Live feeds</div>
|
||||
<div className="value">{liveRecorders.length}</div>
|
||||
<div className="delta">{RECORDERS.length} recorders configured</div>
|
||||
<Sparkline data={spark(2, 5)} color="#2DD4A8" />
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="label"><Icon name="jobs" size={12} /> Jobs queued</div>
|
||||
<div className="value">3<span style={{ color: "var(--text-3)", fontWeight: 400, fontSize: 16 }}> / 247 done</span></div>
|
||||
<div className="delta">avg 4.2 min</div>
|
||||
<Sparkline data={sparkJobs} color="#B57CFA" />
|
||||
<div className="label"><Icon name="jobs" size={12} /> Jobs</div>
|
||||
<div className="value">{runningJobs.length}<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 16 }}> / {JOBS.filter(j => j.status === 'done').length} done</span></div>
|
||||
<div className="delta" style={{ color: failedJobs > 0 ? 'var(--warning)' : '' }}>{failedJobs > 0 ? failedJobs + ' failed' : 'All clear'}</div>
|
||||
<Sparkline data={spark(3, 8)} color="#B57CFA" />
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="label"><Icon name="hdd" size={12} /> Object store</div>
|
||||
<div className="value">64%<span style={{ color: "var(--text-3)", fontWeight: 400, fontSize: 16 }}> of 18 TB</span></div>
|
||||
<div className="delta">11.5 TB used</div>
|
||||
<Sparkline data={sparkStorage} color="#F5A623" />
|
||||
<div className="label"><Icon name="hdd" size={12} /> Cluster nodes</div>
|
||||
<div className="value">{NODES.filter(n => n.online).length}<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 16 }}> / {NODES.length} online</span></div>
|
||||
<div className="delta">Last heartbeat <30s</div>
|
||||
<Sparkline data={spark(4, 4)} color="#F5A623" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="home-grid">
|
||||
<div>
|
||||
<SectionHead title="Live now" onMore={() => navigate("recorders")} moreLabel="All recorders" />
|
||||
<div className="live-feed-grid">
|
||||
{liveRecorders.map((r, i) => (
|
||||
<div key={r.id} className="live-feed-tile" onClick={() => navigate("recorders")}>
|
||||
<div className="live-feed-tile-badge">
|
||||
<span className="badge live">REC</span>
|
||||
</div>
|
||||
<FauxFrame seed={i + 1} />
|
||||
<div className="live-feed-tile-label">
|
||||
<span className="name">{r.name}</span>
|
||||
<span className="time">{r.elapsed}</span>
|
||||
</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>
|
||||
<FauxFrame />
|
||||
<div className="live-feed-tile-label">
|
||||
<span className="name">{r.name}</span>
|
||||
<span className="time">{r.elapsed}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ height: 16 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ height: 16 }} />
|
||||
|
||||
<SectionHead title="Recent activity" />
|
||||
<SectionHead title="Recently added" onMore={() => navigate('library')} moreLabel="All assets" />
|
||||
<div className="activity-feed">
|
||||
{ACTIVITY.map(a => (
|
||||
{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 ${a.kind}`}>
|
||||
<Icon name={iconForKind(a.kind)} size={12} />
|
||||
<div className="activity-icon record">
|
||||
<Icon name={a.type === 'audio' ? 'audio' : 'video'} size={12} />
|
||||
</div>
|
||||
<div className="activity-text">
|
||||
<strong>{a.who}</strong> {a.what} <span className="target">{a.target}</span>
|
||||
<span className="target">{a.name}</span>
|
||||
{a.project && <> in <strong>{a.project}</strong></>}
|
||||
</div>
|
||||
<div className="activity-time">{a.time}</div>
|
||||
<div className="activity-time">{a.updated}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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 }}>
|
||||
{JOBS.slice(0, 5).map(j => (
|
||||
<MiniJobRow key={j.id} job={j} />
|
||||
))}
|
||||
{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="Storage" />
|
||||
<div className="panel" style={{ padding: 16 }}>
|
||||
<StorageBar />
|
||||
<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 => (
|
||||
<div key={n.id} style={{ padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 8, borderBottom: '1px solid var(--border)' }}>
|
||||
<StatusDot status={n.online ? 'online' : 'offline'} />
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500 }}>{n.hostname}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
{n.cpu_usage != null && <span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>CPU {n.cpu_usage}%</span>}
|
||||
{n.mem_used_mb != null && <span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{Math.round(n.mem_used_mb / 1024)}GB RAM</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -99,12 +117,12 @@ function Home({ navigate }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title, onMore, moreLabel = "View all" }) {
|
||||
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>
|
||||
<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}>
|
||||
<button className="btn ghost sm" style={{ marginLeft: 'auto', whiteSpace: 'nowrap' }} onClick={onMore}>
|
||||
{moreLabel}<Icon name="arrowRight" size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -114,67 +132,26 @@ 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 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 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" }} />
|
||||
{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>
|
||||
)}
|
||||
{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)", textAlign: "right", minWidth: 40 }}>
|
||||
{job.status === "running" ? job.eta : job.status}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageBar() {
|
||||
const segments = [
|
||||
{ label: "Master files", color: "#5B7CFA", value: 38 },
|
||||
{ label: "Proxies", color: "#2DD4A8", value: 12 },
|
||||
{ label: "Live captures", color: "#FF3B30", value: 8 },
|
||||
{ label: "Audio", color: "#B57CFA", value: 3 },
|
||||
{ label: "Other", color: "#6B7280", value: 3 },
|
||||
];
|
||||
const cap = 100;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, letterSpacing: "-0.02em" }}>
|
||||
11.5 <span style={{ color: "var(--text-3)", fontSize: 14, fontWeight: 400 }}>TB / 18 TB</span>
|
||||
</div>
|
||||
<span className="badge accent">64% used</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", height: 10, borderRadius: 99, overflow: "hidden", background: "var(--bg-3)" }}>
|
||||
{segments.map((s, i) => (
|
||||
<div key={i} data-tip={`${s.label} — ${s.value}%`} style={{ width: `${(s.value / cap) * 100}%`, background: s.color }} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 12, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 6 }}>
|
||||
{segments.map((s, i) => (
|
||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11.5 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: s.color }} />
|
||||
<span style={{ color: "var(--text-2)" }}>{s.label}</span>
|
||||
<span style={{ marginLeft: "auto", color: "var(--text-3)", fontFamily: "var(--font-mono)", fontSize: 10.5 }}>{s.value}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForKind(kind) {
|
||||
return { comment: "comment", record: "record", job: "jobs", upload: "upload", sync: "refresh", approve: "check", error: "alert" }[kind] || "clock";
|
||||
}
|
||||
|
||||
window.Home = Home;
|
||||
|
|
|
|||
Loading…
Reference in a new issue