dragonflight/services/web-ui/public/screens-home.jsx

167 lines
8.3 KiB
React
Raw Normal View History

// screens-home.jsx
function Home({ navigate }) {
const { RECORDERS, JOBS, ASSETS, NODES } = window.ZAMPP_DATA;
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 nodesOnline = NODES.filter(n => n.status === 'online' || n.online === true).length;
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>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">{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="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</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} /> Cluster nodes</div>
<div className="value">{nodesOnline}<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 16 }}> / {NODES.length} online</span></div>
<div className="delta">Last heartbeat &lt;30s</div>
<Sparkline data={spark(4, 4)} 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>
<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;