Dashboard tab: CPU/mem/load, hwmon temps+fans, net, disks, filesystems, UPS with 10-min sparklines
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
24d64245ec
commit
3c6f4d4639
@@ -0,0 +1,215 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { api, Sample, Series } from './api'
|
||||
import Sparkline from './Sparkline'
|
||||
|
||||
const MAX = 300
|
||||
const S1 = 'var(--s1)', S2 = 'var(--s2)'
|
||||
|
||||
function push(arr: number[], v: number) { arr.push(v); if (arr.length > MAX) arr.shift() }
|
||||
function pad(arr: number[], n: number) { while (arr.length < n) arr.unshift(0) }
|
||||
|
||||
export function fmtBytes(b: number): string {
|
||||
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
let i = 0
|
||||
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
|
||||
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
|
||||
}
|
||||
export function fmtRate(bps: number): string {
|
||||
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
|
||||
let i = 0
|
||||
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
|
||||
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
|
||||
}
|
||||
function fmtUptime(s: number): string {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
const pct = (v: number) => `${Math.round(v)}%`
|
||||
|
||||
function level(frac: number, warn = 0.85, bad = 0.95): '' | 'warn' | 'bad' {
|
||||
return frac >= bad ? 'bad' : frac >= warn ? 'warn' : ''
|
||||
}
|
||||
|
||||
function Meter({ frac, cls }: { frac: number; cls?: string }) {
|
||||
return <div className="meter"><div className={'fill ' + (cls ?? level(frac))} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
|
||||
}
|
||||
|
||||
function Tile({ label, value, sub, children }: { label: string; value: string; sub?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="tile">
|
||||
<div className="label">{label}</div>
|
||||
<div className="value">{value}</div>
|
||||
{sub && <div className="sub">{sub}</div>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({ title, children }: { title: string; children: ReactNode }) {
|
||||
return <section className="card"><h2>{title}</h2>{children}</section>
|
||||
}
|
||||
|
||||
function Legend({ items }: { items: { name: string; color: string }[] }) {
|
||||
return <div className="legend">{items.map((i) => <span key={i.name}><span className="sw" style={{ background: i.color }} />{i.name}</span>)}</div>
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [latest, setLatest] = useState<Sample | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const hist = useRef<Series | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let stop = false
|
||||
let timer: number | undefined
|
||||
const seed = async () => {
|
||||
try { hist.current = await api.metricsHistory(); setLatest(await api.metrics()); setErr('') }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
const tick = async () => {
|
||||
if (stop) return
|
||||
if (document.visibilityState === 'visible') {
|
||||
try {
|
||||
const s = await api.metrics()
|
||||
const h = hist.current
|
||||
if (h) {
|
||||
push(h.t, s.t); push(h.cpu, s.cpu); push(h.mem, s.mem.used)
|
||||
const n = h.t.length
|
||||
for (const x of s.net) {
|
||||
const v = (h.net[x.iface] ??= { rx: [], tx: [] })
|
||||
pad(v.rx, n - 1); pad(v.tx, n - 1); push(v.rx, x.rx_bps); push(v.tx, x.tx_bps)
|
||||
}
|
||||
for (const d of s.disks) {
|
||||
const v = (h.disk[d.name] ??= { r: [], w: [], u: [] })
|
||||
pad(v.r, n - 1); pad(v.w, n - 1); pad(v.u, n - 1); push(v.r, d.read_bps); push(v.w, d.write_bps); push(v.u, d.util_pct)
|
||||
}
|
||||
for (const t of s.temps) {
|
||||
const v = (h.temp[`${t.chip}/${t.label}`] ??= [])
|
||||
pad(v, n - 1); push(v, t.c)
|
||||
}
|
||||
}
|
||||
setLatest(s); setErr('')
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
timer = window.setTimeout(tick, 2000)
|
||||
}
|
||||
seed().then(() => { timer = window.setTimeout(tick, 2000) })
|
||||
const onVis = () => { if (document.visibilityState === 'visible' && !hist.current) seed() }
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
return () => { stop = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVis) }
|
||||
}, [])
|
||||
|
||||
if (!latest) return <div className="dash"><div className="center muted">{err || '…'}</div></div>
|
||||
const h = hist.current
|
||||
const s = latest
|
||||
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
|
||||
|
||||
return (
|
||||
<div className="dash">
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="kpis">
|
||||
<Tile label="CPU" value={pct(s.cpu)} sub={s.cores.length ? `cores ${s.cores.map((c) => Math.round(c)).join(' · ')}` : undefined}>
|
||||
{h && <Sparkline series={[{ name: 'cpu', values: h.cpu, color: S1 }]} max={100} format={pct} height={36} />}
|
||||
</Tile>
|
||||
<Tile label="Memory" value={fmtBytes(s.mem.used)} sub={`of ${fmtBytes(s.mem.total)} · ${pct(memFrac * 100)}${s.swap.total ? ` · swap ${fmtBytes(s.swap.used)}` : ''}`}>
|
||||
{h && <Sparkline series={[{ name: 'used', values: h.mem, color: S1 }]} max={s.mem.total} format={fmtBytes} height={36} />}
|
||||
</Tile>
|
||||
<Tile label="Load" value={s.load.map((l) => l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} />
|
||||
<Tile label="Uptime" value={fmtUptime(s.uptime)} sub={new Date(s.t).toLocaleTimeString()} />
|
||||
</div>
|
||||
|
||||
<div className="cards">
|
||||
<Card title="Temperatures">
|
||||
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>}
|
||||
{s.temps.map((t) => {
|
||||
const limit = t.crit || t.max || 100
|
||||
const cls = t.c >= limit - 5 ? 'bad' : t.max && t.c >= t.max - 15 ? 'warn' : ''
|
||||
return (
|
||||
<div className="row" key={`${t.chip}/${t.label}`}>
|
||||
<span className="k">{t.label}<small>{t.chip}</small></span>
|
||||
<Meter frac={t.c / limit} cls={cls} />
|
||||
<span className="num">{t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Card>
|
||||
|
||||
<Card title="Fans">
|
||||
{s.fans.length === 0 && <div className="empty">No fan sensors reported by this host.</div>}
|
||||
{s.fans.map((f) => (
|
||||
<div className="row" key={`${f.chip}/${f.label}`}>
|
||||
<span className="k">{f.label}<small>{f.chip}</small></span>
|
||||
<span />
|
||||
<span className="num">{f.rpm} rpm</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card title="Network">
|
||||
{s.net.map((x) => (
|
||||
<div className="sub-card" key={x.iface}>
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (x.up ? 'ok' : '')} />
|
||||
<span>{x.iface}</span>
|
||||
<span className="spacer" />
|
||||
<span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>
|
||||
</div>
|
||||
{h?.net[x.iface] && (
|
||||
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
|
||||
)}
|
||||
<div className="legend">
|
||||
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
|
||||
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card title="Disks">
|
||||
{s.disks.map((d) => (
|
||||
<div className="sub-card" key={d.name}>
|
||||
<div className="sub-head">
|
||||
<span>{d.name}</span>
|
||||
<span className="model">{d.model}</span>
|
||||
<span className="spacer" />
|
||||
<span className="num">{d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy</span>
|
||||
</div>
|
||||
<div className="sub-head num">read {fmtRate(d.read_bps)}<span className="spacer" />write {fmtRate(d.write_bps)}</div>
|
||||
{h?.disk[d.name] && (
|
||||
<Sparkline series={[{ name: 'read', values: h.disk[d.name].r, color: S1 }, { name: 'write', values: h.disk[d.name].w, color: S2 }]} format={fmtRate} />
|
||||
)}
|
||||
<Legend items={[{ name: 'read', color: S1 }, { name: 'write', color: S2 }]} />
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card title="Filesystems">
|
||||
{s.fs.map((f) => (
|
||||
<div className="row" key={f.mount}>
|
||||
<span className="k">{f.mount}<small>{f.type}</small></span>
|
||||
{f.size ? <Meter frac={f.used / f.size} /> : <span className="muted">unreachable</span>}
|
||||
<span className="num">{f.size ? `${fmtBytes(f.used)} / ${fmtBytes(f.size)}` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card title="UPS">
|
||||
{!s.ups && <div className="empty">No UPS data (pwrstat not available on this host).</div>}
|
||||
{s.ups && (
|
||||
<>
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (s.ups.source === 'Utility Power' ? 'ok' : 'bad')} />
|
||||
<span>{s.ups.state} · {s.ups.source}</span>
|
||||
<span className="spacer" />
|
||||
<span className="model">{s.ups.model}</span>
|
||||
</div>
|
||||
<div className="row"><span className="k">Battery</span><Meter frac={s.ups.battery_pct / 100} cls={s.ups.battery_pct < 20 ? 'bad' : s.ups.battery_pct < 50 ? 'warn' : ''} /><span className="num">{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min</span></div>
|
||||
<div className="row"><span className="k">Load</span><Meter frac={s.ups.load_pct / 100} /><span className="num">{s.ups.load_w} W · {pct(s.ups.load_pct)}</span></div>
|
||||
<div className="row"><span className="k">Voltage</span><span /><span className="num">in {s.ups.in_v} V · out {s.ups.out_v} V</span></div>
|
||||
{s.ups.last_event && <div className="row"><span className="k">Last event</span><span className="num" style={{ gridColumn: '2 / 4', whiteSpace: 'normal' }}>{s.ups.last_event}</span></div>}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+27
-20
@@ -1,43 +1,50 @@
|
||||
import { useState } from 'react'
|
||||
import { Term } from './api'
|
||||
|
||||
export interface Group { id: string; label: string; icon: string; soon?: boolean }
|
||||
// A group is either a page (clicking the head opens it — Dashboard) or a folder of
|
||||
// leaves (Terminal). `soon` groups are inert placeholders for the roadmap.
|
||||
export interface Group { id: string; label: string; icon: string; soon?: boolean; page?: boolean }
|
||||
|
||||
interface Props {
|
||||
groups: Group[]; terms: Term[]; active: string | null
|
||||
onSelect: (id: string) => void; onNew: () => void
|
||||
groups: Group[]; terms: Term[]; active: string | null; view: string
|
||||
onSelect: (id: string) => void; onView: (id: string) => void; onNew: () => void
|
||||
onRename: (t: Term) => void; onClose: (t: Term) => void
|
||||
}
|
||||
|
||||
export default function Rail({ groups, terms, active, onSelect, onNew, onRename, onClose }: Props) {
|
||||
export default function Rail({ groups, terms, active, view, onSelect, onView, onNew, onRename, onClose }: Props) {
|
||||
const [open, setOpen] = useState<string>('terminal')
|
||||
return (
|
||||
<nav className="rail">
|
||||
{groups.map((g) => {
|
||||
const expanded = open === g.id
|
||||
const expanded = !g.page && open === g.id
|
||||
const isPage = !!g.page && view === g.id
|
||||
return (
|
||||
<div key={g.id} className={'group' + (expanded ? ' open' : '') + (g.soon ? ' soon' : '')}>
|
||||
<button className="group-head" onClick={() => !g.soon && setOpen(expanded ? '' : g.id)} disabled={g.soon}>
|
||||
<span className="chev">{expanded ? '▾' : '▸'}</span>
|
||||
<button className={'group-head' + (isPage ? ' active' : '')} disabled={g.soon}
|
||||
onClick={() => { if (g.soon) return; if (g.page) onView(g.id); else setOpen(expanded ? '' : g.id) }}>
|
||||
<span className="chev">{g.page ? '' : expanded ? '▾' : '▸'}</span>
|
||||
<span className="gicon">{g.icon}</span>
|
||||
<span>{g.label}</span>
|
||||
{g.soon && <span className="badge">soon</span>}
|
||||
</button>
|
||||
{expanded && g.id === 'terminal' && (
|
||||
<div className="leaves">
|
||||
{terms.map((t) => (
|
||||
<div key={t.id} className={'leaf' + (t.id === active ? ' active' : '') + (t.running ? '' : ' exited')}
|
||||
onClick={() => onSelect(t.id)}>
|
||||
<span className={'sdot' + (t.running ? ' run' : '')} />
|
||||
<span className="lname" title={t.name}>{t.name}</span>
|
||||
{t.id === active && (
|
||||
<span className="leaf-actions">
|
||||
<button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>
|
||||
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{terms.map((t) => {
|
||||
const sel = view === 'terminal' && t.id === active
|
||||
return (
|
||||
<div key={t.id} className={'leaf' + (sel ? ' active' : '') + (t.running ? '' : ' exited')}
|
||||
onClick={() => onSelect(t.id)}>
|
||||
<span className={'sdot' + (t.running ? ' run' : '')} />
|
||||
<span className="lname" title={t.name}>{t.name}</span>
|
||||
{sel && (
|
||||
<span className="leaf-actions">
|
||||
<button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>
|
||||
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button className="leaf new" onClick={onNew}>+ New Terminal</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+15
-7
@@ -3,15 +3,18 @@ import { api, Me, Term } from './api'
|
||||
import Rail, { Group } from './Rail'
|
||||
import TerminalPane, { TermHandle } from './TerminalPane'
|
||||
import KeyBar from './KeyBar'
|
||||
import Dashboard from './Dashboard'
|
||||
|
||||
const GROUPS: Group[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
|
||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||
{ id: 'services', label: 'Services', icon: '⚙', soon: true },
|
||||
{ id: 'cron', label: 'Cron', icon: '⏱', soon: true },
|
||||
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔', soon: true },
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'dashboard'
|
||||
|
||||
function fmtUptime(s: number): string {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
@@ -20,6 +23,7 @@ function fmtUptime(s: number): string {
|
||||
export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
|
||||
const [terms, setTerms] = useState<Term[]>([])
|
||||
const [active, setActive] = useState<string | null>(null)
|
||||
const [view, setView] = useState<View>('terminal')
|
||||
const [drawer, setDrawer] = useState(false)
|
||||
const [online, setOnline] = useState(true)
|
||||
const handles = useRef<Record<string, TermHandle>>({})
|
||||
@@ -36,6 +40,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
const t = await api.createTerminal()
|
||||
await reload()
|
||||
setActive(t.id)
|
||||
setView('terminal')
|
||||
setDrawer(false)
|
||||
}
|
||||
async function rename(t: Term) {
|
||||
@@ -51,6 +56,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
async function logout() { await api.logout(); onLoggedOut() }
|
||||
|
||||
const activeTerm = terms.find((t) => t.id === active)
|
||||
const termView = view === 'terminal'
|
||||
|
||||
return (
|
||||
<div className={'app' + (drawer ? ' drawer-open' : '')}>
|
||||
@@ -65,20 +71,22 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
</header>
|
||||
|
||||
<div className="body">
|
||||
<Rail groups={GROUPS} terms={terms} active={active}
|
||||
onSelect={(id) => { setActive(id); setDrawer(false) }}
|
||||
<Rail groups={GROUPS} terms={terms} active={active} view={view}
|
||||
onSelect={(id) => { setActive(id); setView('terminal'); setDrawer(false) }}
|
||||
onView={(id) => { setView(id as View); setDrawer(false) }}
|
||||
onNew={create} onRename={rename} onClose={close} />
|
||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||
|
||||
<main className="main">
|
||||
{terms.length === 0 && (
|
||||
{view === 'dashboard' && <Dashboard />}
|
||||
{termView && terms.length === 0 && (
|
||||
<div className="center muted">
|
||||
<div>No terminals.</div>
|
||||
<button className="primary" onClick={create}>+ New Terminal</button>
|
||||
</div>
|
||||
)}
|
||||
{terms.map((t) => (
|
||||
<TerminalPane key={t.id} id={t.id} active={t.id === active}
|
||||
<TerminalPane key={t.id} id={t.id} active={termView && t.id === active}
|
||||
register={(h) => { if (h) handles.current[t.id] = h; else delete handles.current[t.id] }}
|
||||
onStatus={(ok) => { if (t.id === active) setOnline(ok) }}
|
||||
onExit={reload} />
|
||||
@@ -86,14 +94,14 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{activeTerm && <KeyBar send={(s) => handles.current[activeTerm.id]?.input(s)}
|
||||
{termView && activeTerm && <KeyBar send={(s) => handles.current[activeTerm.id]?.input(s)}
|
||||
setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
|
||||
|
||||
<footer className="footer">
|
||||
<span>v{me.version}</span>
|
||||
<span>up {fmtUptime(me.uptime)}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{activeTerm ? activeTerm.name : ''}</span>
|
||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : activeTerm ? activeTerm.name : ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
export interface SparkSeries { name: string; values: number[]; color: string }
|
||||
|
||||
interface Props {
|
||||
series: SparkSeries[]
|
||||
max?: number // fixed ceiling (e.g. 100 for percent); otherwise auto from data
|
||||
format: (v: number) => string
|
||||
height?: number
|
||||
}
|
||||
|
||||
const W = 300
|
||||
|
||||
// Thin-line sparkline with a crosshair tooltip. Stroke stays 2px at any width via
|
||||
// non-scaling-stroke; the box is stretched with preserveAspectRatio="none".
|
||||
export default function Sparkline({ series, max, format, height = 44 }: Props) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const n = Math.max(...series.map((s) => s.values.length), 0)
|
||||
if (n < 2) return <div className="spark" style={{ height }} />
|
||||
|
||||
let top = max ?? 0
|
||||
if (max === undefined) for (const s of series) for (const v of s.values) if (v > top) top = v
|
||||
if (top <= 0) top = 1
|
||||
const H = height
|
||||
const x = (i: number) => (i / (n - 1)) * W
|
||||
const y = (v: number) => H - 2 - (Math.min(v, top) / top) * (H - 4)
|
||||
const path = (vals: number[]) => {
|
||||
const off = n - vals.length
|
||||
return vals.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i + off).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||||
}
|
||||
|
||||
const move = (clientX: number) => {
|
||||
const r = box.current?.getBoundingClientRect()
|
||||
if (!r || r.width === 0) return
|
||||
const f = Math.min(1, Math.max(0, (clientX - r.left) / r.width))
|
||||
setHover(Math.round(f * (n - 1)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={box} className="spark" style={{ height }}
|
||||
onMouseMove={(e) => move(e.clientX)} onMouseLeave={() => setHover(null)}
|
||||
onTouchStart={(e) => move(e.touches[0].clientX)} onTouchMove={(e) => move(e.touches[0].clientX)} onTouchEnd={() => setHover(null)}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
|
||||
<line x1={0} x2={W} y1={H - 2} y2={H - 2} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
{series.map((s) => (
|
||||
<path key={s.name} d={path(s.values)} fill="none" stroke={s.color} strokeWidth={2}
|
||||
strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
{hover !== null && (
|
||||
<line x1={x(hover)} x2={x(hover)} y1={0} y2={H} stroke="var(--muted)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
)}
|
||||
</svg>
|
||||
{hover !== null && (
|
||||
<div className="tip" style={{ left: `${(hover / (n - 1)) * 100}%` }}>
|
||||
{series.map((s) => {
|
||||
const v = s.values[hover - (n - s.values.length)]
|
||||
return v === undefined ? null : <span key={s.name}><span className="sw" style={{ background: s.color }} />{s.name} {format(v)} </span>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,27 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
|
||||
export interface Me { host: string; user: string; version: string; load: string; uptime: number }
|
||||
export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }
|
||||
|
||||
export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number }
|
||||
export interface Fan { chip: string; label: string; rpm: number }
|
||||
export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number }
|
||||
export interface Disk { name: string; model?: string; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
||||
export interface FS { mount: string; type: string; size: number; used: number }
|
||||
export interface UPS { model: string; state: string; source: string; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
||||
export interface Sample {
|
||||
t: number; cpu: number; cores: number[]; load: [number, number, number]
|
||||
mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number }
|
||||
temps: Temp[]; fans: Fan[]; net: NetIf[]; disks: Disk[]; fs: FS[]; ups: UPS | null; uptime: number
|
||||
}
|
||||
export interface Series {
|
||||
t: number[]; cpu: number[]; mem: number[]
|
||||
net: Record<string, { rx: number[]; tx: number[] }>
|
||||
disk: Record<string, { r: number[]; w: number[]; u: number[] }>
|
||||
temp: Record<string, number[]>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
metrics: () => req<Sample>('GET', '/api/metrics'),
|
||||
metricsHistory: () => req<Series>('GET', '/api/metrics/history'),
|
||||
login: (password: string) => req<void>('POST', '/api/login', { password }),
|
||||
logout: () => req<void>('POST', '/api/logout'),
|
||||
me: () => req<Me>('GET', '/api/me'),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
--bg: #111418; --bg2: #171b21; --bg3: #1e242c; --border: #2a313b;
|
||||
--fg: #d0d7de; --muted: #7d8590; --accent: #e3a33b; --accent2: #58a6ff;
|
||||
--ok: #3fb950; --bad: #ff7b72;
|
||||
--s1: #3987e5; --s2: #c98500; --track: rgba(57, 135, 229, .18);
|
||||
--header-h: 44px; --footer-h: 28px; --rail-w: 220px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
@@ -77,3 +78,31 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.drawer-open .backdrop { display: block; position: absolute; inset: 0; z-index: 9; background: rgba(0,0,0,.5); }
|
||||
.footer { font-size: 11px; }
|
||||
}
|
||||
|
||||
/* dashboard */
|
||||
.dash { position: absolute; inset: 0; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; background: var(--bg); -webkit-overflow-scrolling: touch; }
|
||||
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; align-items: start; }
|
||||
.tile, .card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; min-width: 0; }
|
||||
.tile .label, .card h2 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||
.tile .value { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.2; }
|
||||
.tile .sub { font-size: 12px; color: var(--muted); margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||
.tile .spark { margin-top: 8px; }
|
||||
.card .empty { color: var(--muted); font-size: 13px; }
|
||||
.row { display: grid; grid-template-columns: minmax(90px, 1fr) 2fr auto; gap: 10px; align-items: center; padding: 4px 0; font-size: 13px; min-width: 0; }
|
||||
.row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .row .k small { color: var(--muted); margin-left: 6px; }
|
||||
.num { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-size: 12px; white-space: nowrap; }
|
||||
.meter { height: 6px; background: var(--track); border-radius: 3px; overflow: hidden; }
|
||||
.meter .fill { height: 100%; background: var(--s1); border-radius: 3px; transition: width .3s; }
|
||||
.meter .fill.warn { background: var(--s2); } .meter .fill.bad { background: var(--bad); }
|
||||
.sub-card { padding: 8px 0; border-top: 1px solid var(--border); } .sub-card:first-of-type { border-top: 0; padding-top: 0; }
|
||||
.sub-head { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 4px; } .sub-head .spacer { flex: 1; }
|
||||
.sub-head .model { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.legend { display: flex; gap: 12px; font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||
.sw { display: inline-block; width: 12px; height: 2px; vertical-align: middle; margin-right: 5px; border-radius: 1px; }
|
||||
.spark { position: relative; width: 100%; height: 44px; touch-action: pan-y; }
|
||||
.spark svg { display: block; width: 100%; height: 100%; }
|
||||
.spark .tip { position: absolute; top: 0; transform: translateX(-50%); background: var(--bg3); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px; font-size: 11px; white-space: nowrap; pointer-events: none; font-variant-numeric: tabular-nums; }
|
||||
.status { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.rail .group-head.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
@media (max-width: 800px) { .dash { padding: 8px; gap: 8px; } .kpis, .cards { gap: 8px; } .cards { grid-template-columns: 1fr; } }
|
||||
|
||||
Reference in New Issue
Block a user