diff --git a/SENSORS.md b/SENSORS.md index 0713405..b55d264 100644 --- a/SENSORS.md +++ b/SENSORS.md @@ -199,6 +199,15 @@ GET /api/fleet/sensors/query?machines=grid,wyse&cpu=cpu.usage&failed=services.f → {"core": {"cpu": 4.8, "up": 50211}, "grid": {"cpu": 0.4, "up": 437177}, "mac": {"error": "timeout"}} ``` +## The Top tab + +Ham Lab → **Top** is a whole machine on one screen in the style of btop: black ground, boxes +with their name set into the border, curves drawn as dots that climb green → yellow → red. +It reads nothing of its own — the sensor stream for everything measured, `/api/procs` for the +table — so it works on any machine the console can reach, whether or not btop is installed +there, and it follows the same refresh rate and history window as the rest of the console. +(The look is btop's, `web/src/Top.tsx` is ours; btop itself is Apache-2.0.) + ## How far back a history reaches Every machine keeps **300 points per curve**, whatever the window — so the window sets how diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 977a56a..35a5bde 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -14,6 +14,7 @@ import Updates from './Updates' import Disks from './Disks' import Files from './Files' import Procs from './Procs' +import Top from './Top' import Machines from './Machines' import Settings from './Settings' import MachineSwitcher from './MachineSwitcher' @@ -50,6 +51,7 @@ const BellIcon = ( const GROUPS: Group[] = [ { id: 'dashboard', label: 'Dashboard', icon: '◔' }, + { id: 'top', label: 'Top', icon: '▚' }, { id: 'procs', label: 'Processes', icon: '≡' }, { id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'services', label: 'Services', icon: '◈' }, @@ -64,8 +66,8 @@ const GROUPS: Group[] = [ { id: 'settings', label: 'Settings', icon: '⚙' }, ] -type View = 'terminal' | 'dashboard' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings' -const VIEWS: View[] = ['terminal', 'dashboard', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings'] +type View = 'terminal' | 'dashboard' | 'top' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings' +const VIEWS: View[] = ['terminal', 'dashboard', 'top', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings'] const HUB_ONLY: View[] = ['machines', 'settings', 'alerts'] // URL hash is the source of truth for where you are: #//. Back and @@ -252,6 +254,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {view === 'widgets' && } {view === 'docker' && } {view === 'alerts' && m.name)} />} + {view === 'top' && } {view === 'procs' && } {view === 'services' && } {view === 'cron' && } @@ -283,7 +286,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} + {view === 'top' ? 'Top' : view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} ) diff --git a/web/src/Top.tsx b/web/src/Top.tsx new file mode 100644 index 0000000..7465ae3 --- /dev/null +++ b/web/src/Top.tsx @@ -0,0 +1,433 @@ +// A whole machine on one screen, drawn the way btop draws it: black ground, boxes with their +// name set into the border, and every curve made of coloured dots that go green → yellow → red +// as they climb. The numbers are Ham Lab's own — the sensor stream for everything measured and +// /api/procs for the table — so this works on any machine the console can see, whether or not +// btop is installed on it. +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { api, ProcTable, Proc, Sample } from './api' +import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter' +import { fetchCatalog, fetchHistories, useSensorStream } from './sensors' +import { fmtMs, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs' +import { Loading, askYes, toast } from './ui' + +const MAX = 300 // points kept per curve, as the machines keep them + +// ---- the palette, btop's default theme ---- +const GRAD = { + cpu: ['#00d35c', '#c8e000', '#ff3c3c'], + used: ['#ff5c5c', '#ff8c5c', '#ffc65c'], + avail: ['#ffb05c', '#ffd05c', '#fff05c'], + cached: ['#3ca0ff', '#5cc8ff', '#8ce4ff'], + free: ['#00d35c', '#4ce07a', '#8cf0a0'], + down: ['#c850ff', '#e050d0', '#ff50a0'], + up: ['#5060ff', '#50a0ff', '#50d0ff'], + temp: ['#4c8cff', '#7cb0ff', '#a0d0ff'], + io: ['#ffb05c', '#ff8c5c', '#ff5c5c'], +} as const +type GradName = keyof typeof GRAD + +// mix(a, b, t) on hex, so a dot's colour can fall anywhere along a three-stop gradient +function mix(a: string, b: string, t: number): string { + const p = (h: string, i: number) => parseInt(h.slice(1 + i * 2, 3 + i * 2), 16) + const c = [0, 1, 2].map((i) => Math.round(p(a, i) + (p(b, i) - p(a, i)) * t)) + return `rgb(${c[0]},${c[1]},${c[2]})` +} +function at(g: readonly string[], t: number): string { + const x = Math.max(0, Math.min(1, t)) * (g.length - 1) + const i = Math.min(g.length - 2, Math.floor(x)) + return mix(g[i], g[i + 1], x - i) +} + +// ---- the dot graph ---- +// One dot column per sample, filled from the bottom, each dot coloured by how high it sits. +function Graph({ values, tick, max, grad, flip = false }: { values: number[]; tick: number; max?: number; grad: GradName; flip?: boolean }) { + const ref = useRef(null) + const box = useRef(null) + const [[w, height], setSize] = useState([0, 0]) + useEffect(() => { + if (!box.current) return + const ro = new ResizeObserver((e) => setSize([Math.floor(e[0].contentRect.width), Math.floor(e[0].contentRect.height)])) + ro.observe(box.current) + return () => ro.disconnect() + }, []) + useEffect(() => { + const c = ref.current + if (!c || w < 4 || height < 4) return + const dpr = window.devicePixelRatio || 1 + c.width = Math.round(w * dpr); c.height = Math.round(height * dpr) + const g = c.getContext('2d')! + g.setTransform(dpr, 0, 0, dpr, 0, 0) + g.clearRect(0, 0, w, height) + const step = 3, rows = Math.max(1, Math.floor(height / step)), cols = Math.max(1, Math.floor(w / step)) + // the curve always spans the box: more points than columns and the oldest fall off the + // left, fewer and they are stretched, so the width always means the whole window + const have = values.length > cols ? values.slice(-cols) : values + const seen = have.length >= cols ? have : Array.from({ length: have.length ? cols : 0 }, (_, i) => have[Math.min(have.length - 1, Math.floor((i * have.length) / cols))]) + const top = max ?? Math.max(1, ...seen) + const stops = GRAD[grad] + for (let i = 0; i < seen.length; i++) { + const lit = Math.round((Math.max(0, seen[i]) / top) * rows) + const x = w - (seen.length - i) * step + for (let r = 0; r < lit; r++) { + const t = rows > 1 ? r / (rows - 1) : 1 + g.fillStyle = at(stops, t) + const y = flip ? r * step + 1 : height - (r + 1) * step + 1 + g.fillRect(x, y, 2, 2) + } + } + }, [values, tick, w, height, max, grad, flip]) // tick: the curve is appended to in place + return
+} + +// ---- the dotted meter ---- +function Meter({ pct, grad }: { pct: number; grad: GradName }) { + const p = Math.max(0, Math.min(100, pct)) + const g = GRAD[grad] + return ( + + + + + ) +} + +// ---- a box, with its name set into the border ---- +function Box({ n, name, right, className = '', children }: { n?: number; name: string; right?: React.ReactNode; className?: string; children: React.ReactNode }) { + return ( +
+ {n !== undefined && {'¹²³⁴⁵⁶'[n - 1]}}{name} + {right !== undefined && {right}} +
{children}
+
+ ) +} + +// ---- formatting, btop's short units ---- +const K = 1024 +function bytes(b: number, digits = 2): string { + const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'] + let i = 0 + while (b >= K && i < u.length - 1) { b /= K; i++ } + return `${i === 0 ? Math.round(b) : b.toFixed(digits)} ${u[i]}` +} +function shortBytes(b: number): string { // 720M, 9.4M, 0B — the process table's width is precious + const u = ['B', 'K', 'M', 'G', 'T'] + let i = 0 + while (b >= K && i < u.length - 1) { b /= K; i++ } + return `${b >= 100 || i === 0 ? Math.round(b) : b.toFixed(1)}${u[i]}` +} +function bits(bps: number): string { + const u = ['bps', 'Kibps', 'Mibps', 'Gibps'] + let b = bps * 8, i = 0 + while (b >= K && i < u.length - 1) { b /= K; i++ } + return `${i === 0 ? Math.round(b) : b.toFixed(2)} ${u[i]}` +} +function rate(bps: number): string { + const u = ['B/s', 'KiB/s', 'MiB/s', 'GiB/s'] + let b = bps, i = 0 + while (b >= K && i < u.length - 1) { b /= K; i++ } + return `${i === 0 ? Math.round(b) : b.toFixed(2)} ${u[i]}` +} +const pad = (s: string | number, n: number) => String(s).padStart(n) +function upfor(sec: number): string { + const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60), s = Math.floor(sec % 60) + return (d ? `${d}d ` : '') + `${pad(h, 2)}:${pad(m, 2)}${d ? '' : ':' + pad(s, 2)}`.replace(/ /g, '0') +} + +// A per-process curve, as braille: cheap enough to draw one for every row, every tick. +const BR = [0x01, 0x02, 0x04, 0x40, 0x08, 0x10, 0x20, 0x80] // dot order: (col,row) → bit +function braille(values: number[], cells = 7, max = 100): string { + const cols = cells * 2 + const seen = values.slice(-cols) + const top = Math.max(1, max) + let out = '' + for (let c = 0; c < cells; c++) { + let bitsOn = 0 + for (let half = 0; half < 2; half++) { + const i = seen.length - cols + c * 2 + half + const v = i >= 0 && i < seen.length ? seen[i] : 0 + const lit = Math.max(0, Math.min(4, Math.round((v / top) * 4))) + for (let r = 0; r < lit; r++) bitsOn |= BR[half * 4 + (3 - r)] + } + out += String.fromCharCode(0x2800 + bitsOn) + } + return out +} + +// ---- the curves this page keeps ---- +const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util' + +function useCurves(live: Values | null, t: number, refreshMs: number, historySec: number, resumed: number) { + const cur = useRef>(new Map()) + const lastT = useRef(0) + const binN = useRef(1) + const [ticks, bump] = useState(0) + const seed = useCallback(async () => { + try { + const hs = await fetchHistories(CURVES) + const m = new Map() + let newest = 0 + for (const h of hs) { m.set(h.id, h.v.slice(-MAX)); newest = Math.max(newest, h.t[h.t.length - 1] ?? 0) } + cur.current = m; lastT.current = newest; binN.current = 1 + bump((x) => x + 1) + } catch { /* the curves fill in live instead */ } + }, []) + useEffect(() => { seed() }, [seed, resumed]) + useEffect(() => { + if (!live || !t) return + const fresh = lastT.current === 0 || t - lastT.current >= strideMs(historySec, refreshMs) + binN.current = fresh ? 1 : binN.current + 1 + if (fresh) lastT.current = t + const n = binN.current + for (const [id, v] of Object.entries(live)) { + if (typeof v !== 'number') continue + if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$/.test(id)) continue + const arr = cur.current.get(id) ?? [] + if (!cur.current.has(id)) cur.current.set(id, arr) + if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() } + else arr[arr.length - 1] += (v - arr[arr.length - 1]) / n + } + bump((x) => x + 1) + }, [live, t, refreshMs, historySec]) + return [cur.current, ticks] as const +} + +// ---- the page ---- +export default function Top() { + const refreshMs = useRefreshMs() + const historySec = useHistorySec() + const [clock, setClock] = useState(() => new Date()) + useEffect(() => { const i = window.setInterval(() => setClock(new Date()), 1000); return () => window.clearInterval(i) }, []) + + const catalog = useRef(new Map()) + const refetching = useRef(false) + const [ready, setReady] = useState(false) + const stream = useSensorStream(MONITOR_SHAPE, { full: false, everyMs: refreshMs }) + useEffect(() => { fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])); setReady(true) }).catch(() => setReady(true)) }, []) + const values = useMemo(() => (stream.data ? mergeValues(stream.data) : null), [stream.data]) + useEffect(() => { + if (!values || catalog.current.size === 0 || refetching.current || !stale(values, catalog.current)) return + refetching.current = true + fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000)) + }, [values]) + const s: Sample | null = useMemo(() => (values && ready ? sampleOf(values, catalog.current) : null), [values, ready]) + const [curves, tick] = useCurves(values, s?.t ?? 0, refreshMs, historySec, stream.resumed) + + // the process table, and the CPU model, come from the same place the Processes page reads + const [pt, setPt] = useState(null) + const [err, setErr] = useState('') + useEffect(() => { + let stop = false + const poll = async () => { try { const r = await api.procs(); if (!stop) { setPt(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') } } + poll() + const i = window.setInterval(poll, Math.max(1000, refreshMs)) + return () => { stop = true; window.clearInterval(i) } + }, [refreshMs]) + + const [sort, setSort] = useState<'cpu' | 'mem' | 'pid' | 'name'>('cpu') + const [rev, setRev] = useState(false) + const [filter, setFilter] = useState('') + const [sel, setSel] = useState(null) + + // a short cpu curve per process, built from what we have seen since the page opened + const pcurve = useRef>(new Map()) + useEffect(() => { + if (!pt) return + const live = new Set() + for (const p of pt.procs) { + live.add(p.pid) + const a = pcurve.current.get(p.pid) ?? [] + a.push(p.cpu); if (a.length > 14) a.shift() + pcurve.current.set(p.pid, a) + } + for (const pid of pcurve.current.keys()) if (!live.has(pid)) pcurve.current.delete(pid) + }, [pt]) + + const procs = useMemo(() => { + if (!pt) return [] + const f = filter.trim().toLowerCase() + const list = pt.procs.filter((p) => !f || p.name.toLowerCase().includes(f) || (p.cmd ?? '').toLowerCase().includes(f) || p.user.toLowerCase().includes(f) || String(p.pid) === f) + const key = (p: Proc) => (sort === 'cpu' ? p.cpu : sort === 'mem' ? p.rss : sort === 'pid' ? p.pid : 0) + list.sort((a, b) => (sort === 'name' ? a.name.localeCompare(b.name) : key(b) - key(a))) + return rev ? list.reverse() : list + }, [pt, filter, sort, rev]) + + const signal = async (p: Proc, sig: 'TERM' | 'KILL') => { + if (!(await askYes(`Send SIG${sig} to ${p.name} (pid ${p.pid})?`))) return + try { await api.procSignal(p.pid, sig); toast(`SIG${sig} sent to ${p.pid}`) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + + if (!s && !pt) return
+ + const cpu = s?.cpu ?? pt?.cpu ?? 0 + const cores = (s?.cores?.length ? s.cores : pt?.cores) ?? [] + const load = s?.load ?? pt?.load ?? [0, 0, 0] + const mem = s?.mem ?? pt?.mem ?? { total: 0, used: 0, avail: 0 } + const uptime = s?.uptime ?? pt?.uptime ?? 0 + const model = (pt?.cpu_model ?? '').replace(/\(R\)|\(TM\)|CPU|Processor/g, '').replace(/\s+/g, ' ').trim() + // the hottest thing that looks like a CPU sensor, and one per core where the chip names them + const coreTemp = (i: number) => s?.temps.find((t) => new RegExp(`core ${i}\\b`, 'i').test(t.label))?.c + const cpuTemp = pt?.cpu_temp ?? s?.temps.find((t) => /package|tctl|tdie|cpu/i.test(t.label))?.c ?? coreTemp(0) + const cached = (mem as { cached?: number }).cached ?? 0 + const free = (mem as { free?: number }).free ?? Math.max(0, mem.total - mem.used - cached) + const swap = s?.swap ?? pt?.swap + const net = (s?.net ?? pt?.net ?? []).filter((n) => n.up && n.iface !== 'lo') + const iface = net[0] + const fs = s?.fs ?? [] + const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0) + + return ( +
+ + {clock.toLocaleTimeString([], { hour12: false })} + + + {fmtMs(refreshMs)} + + + }> +
+
+ +
up {upfor(uptime)}
+
+
+
{model || 'cpu'}{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}
+
+ CPU + + {Math.round(cpu)}% + {cpuTemp !== undefined && <>{Math.round(cpuTemp)}°C} +
+ {cores.map((v, i) => { + const t = coreTemp(i) + return ( +
+ C{i} + {braille(curves.get(`cpu.core.${i}.usage`) ?? [], 9)} + {Math.round(v)}% + {t !== undefined && <>{Math.round(t)}°C} +
+ ) + })} +
Load AVG:{load.map((x) => x.toFixed(2)).join(' ')}
+
+
+
+ + +
Total:{bytes(mem.total)}
+ {([['Used', mem.used, 'used'], ['Available', mem.avail, 'avail'], ['Cached', cached, 'cached'], ['Free', free, 'free']] as const).map(([k, v, g]) => ( +
+
{k}:{bytes(v)}
+
{Math.round(pct(v, mem.total))}%
+ +
+ ))} +
+ + +
+ {swap && swap.total > 0 && } + {fs.map((f) => { + const d = s?.disks.find((x) => f.mount === '/' ? /^(nvme0n1|sda|vda|mmcblk0)$/.test(x.name) : false) + return + })} + {fs.length === 0 &&
no filesystems reported
} +
+
+ + {iface.iface} : undefined}> + {iface ? ( +
+
+
+ {rate(Math.max(1, ...(curves.get(`net.${segOf(iface.iface)}.rx`) ?? [0])))} + +
+
+ + {rate(Math.max(1, ...(curves.get(`net.${segOf(iface.iface)}.tx`) ?? [0])))} +
+
+
+
download
+
▼ {rate(iface.rx_bps)}({bits(iface.rx_bps)})
+
▼ Top:({bits(Math.max(0, ...(curves.get(`net.${segOf(iface.iface)}.rx`) ?? [0])))})
+
▼ Total:{bytes(iface.rx_total)}
+
+
▲ {rate(iface.tx_bps)}({bits(iface.tx_bps)})
+
▲ Top:({bits(Math.max(0, ...(curves.get(`net.${segOf(iface.iface)}.tx`) ?? [0])))})
+
▲ Total:{bytes(iface.tx_total)}
+
upload
+
+
+ ) :
no interface is up
} + + + + + {(['pid', 'name', 'mem', 'cpu'] as const).map((k) => )} + + + }> +
+ setFilter(e.target.value)} placeholder="filter" spellCheck={false} autoCapitalize="off" autoCorrect="off" /> +
+ Pid:Program:Command: + Threads:User:MemBCpu% +
+ +
+ {sel !== null && procs.find((p) => p.pid === sel) ? (() => { const p = procs.find((x) => x.pid === sel)!; return <> + {p.pid} {p.name} + + + })() : <>select a process to signal it} + {procs.length}/{pt?.total ?? 0} +
+
+
+
+ ) +} + +// The rows are the heaviest thing on the page — hundreds of them — so they are redrawn when the +// table is read again, not on every tick of the curves above them. +const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: Proc[]; sel: number | null; onSel: (p: number | null) => void; curves: React.RefObject>; empty: string }) { + return ( +
+ {procs.map((p) => { + const c = curves.current?.get(p.pid) ?? [] + return ( +
onSel(sel === p.pid ? null : p.pid)}> + {p.pid} + {p.name} + {p.cmd || p.name} + {p.threads} + {p.user} + {shortBytes(p.rss)} + {braille(c, 7, Math.max(5, ...(c.length ? c : [0])))} + {p.cpu.toFixed(1)} +
+ ) + })} + {procs.length === 0 &&
{empty}
} +
+ ) +}) + +function Disk({ name, size, used, io }: { name: string; size: number; used: number; io?: number }) { + const upct = size > 0 ? (used / size) * 100 : 0 + return ( +
+
{name}{bytes(size)}
+ {io !== undefined &&
IO%
} +
Used:{Math.round(upct)}%{bytes(used, 1)}
+
Free:{Math.round(100 - upct)}%{bytes(Math.max(0, size - used), 1)}
+
+ ) +} + +// the sensor ID segment for an interface name, the same rule the server uses +const segOf = (name: string) => name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '') diff --git a/web/src/styles.css b/web/src/styles.css index 67090d5..47d3083 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -534,3 +534,94 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, .clash { color: var(--s2); font-size: 12px; overflow-wrap: anywhere; } @media (max-width: 800px) { .stack-new .two { grid-template-columns: 1fr; } } .dtable td.row-end { text-align: right; white-space: nowrap; width: 1%; } .dtable .badge { margin-left: 0; } + +/* ---- Top: a machine drawn the way btop draws it ---- + Its own palette, not the skin's: black ground, dim blue hairlines, dotted curves that climb + green → red. Fixed-width everywhere, because that is the look. */ +.top { position: absolute; inset: 0; background: #000; color: #cccccc; font-family: var(--mono); font-size: 13px; line-height: 1.35; + padding: 6px; display: grid; gap: 4px; grid-template-columns: 1fr 1fr 1.6fr; grid-template-rows: minmax(200px, 1fr) auto minmax(170px, 1fr); overflow-y: auto; } +/* every box says how little it can be: a window too short to hold them all scrolls, rather than + cutting the bottom off whichever box the grid ran out of room for */ +.t-cpu-graph { min-height: 120px; } .t-net-graphs { min-height: 130px; } .top-proc { min-height: 300px; } +.top-mem, .top-disks { min-height: 165px; } /* a grid row set to auto will otherwise squeeze them to nothing */ +.top-cpu { grid-column: 1 / -1; } .top-mem { grid-column: 1; grid-row: 2; } .top-disks { grid-column: 2; grid-row: 2; } +.top-net { grid-column: 1 / 3; grid-row: 3; } .top-proc { grid-column: 3; grid-row: 2 / 4; } +.tbox { position: relative; border: 1px solid #303a4a; border-radius: 3px; min-width: 0; min-height: 0; display: flex; flex-direction: column; } +.tbox-in { flex: 1; min-height: 0; min-width: 0; padding: 9px 8px 6px; display: flex; flex-direction: column; overflow: hidden; } +.tbox-name, .tbox-right { position: absolute; top: -9px; background: #000; padding: 0 4px; font-size: 12px; white-space: nowrap; z-index: 1; } +.tbox-name { left: 8px; color: #dddddd; } .tbox-name b { color: #ee4b4b; font-weight: 400; } +.tbox-right { right: 8px; color: #7b8496; display: flex; align-items: center; gap: 8px; } +.tbox-right button { background: none; border: 0; color: #7b8496; font-family: var(--mono); font-size: 12px; padding: 0 3px; cursor: pointer; } +.tbox-right button:hover, .tbox-right button.on { color: #ee4b4b; } +.t-clock { color: #dddddd; } .t-rate { display: inline-flex; align-items: center; gap: 2px; } +.t-dim { color: #6b7280; } .top .spacer { flex: 1; } + +/* cpu */ +.t-cpu { display: flex; gap: 8px; min-width: 0; flex: 1; min-height: 0; } +.t-cpu-graph { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; } +.t-up { color: #6b7280; font-size: 12px; padding-top: 2px; } +.t-cores { width: 310px; flex-shrink: 0; border: 1px solid #303a4a; border-radius: 3px; padding: 4px 6px; display: flex; flex-direction: column; gap: 1px; overflow-y: auto; scrollbar-width: thin; } +.t-cores-head { display: flex; gap: 8px; color: #7b8496; font-size: 12px; } .t-cores-head b { color: #dddddd; font-weight: 400; } +.t-core { display: flex; align-items: center; gap: 6px; } +.t-core-n { width: 30px; color: #cccccc; } .t-core-p { width: 34px; text-align: right; color: #dddddd; } +.t-core-g { flex: 1; color: #00d35c; letter-spacing: 0; overflow: hidden; white-space: nowrap; } +.t-core .tm { flex: 1; } .t-core-t { flex: 0 0 62px; display: flex; } .t-core-c { width: 42px; text-align: right; color: #4c8cff; } +.t-load { display: flex; margin-top: 2px; color: #cccccc; } + +/* the dot graph and the dotted meter */ +.tg { width: 100%; height: 100%; flex: 1; min-height: 0; overflow: hidden; } +.tm { position: relative; display: inline-block; height: 9px; flex: 1; min-width: 24px; vertical-align: middle; } +.tm-t, .tm-f { position: absolute; inset: 0; -webkit-mask-image: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 5px); mask-image: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 5px); } +.tm-t { background: #23293a; } + +/* mem and disks */ +.t-row { display: flex; align-items: center; gap: 6px; } .t-row b { color: #dddddd; font-weight: 400; } +.t-sub { color: #7b8496; font-size: 12px; } +.t-mem { margin-bottom: 4px; } .t-mem .t-sub { margin-top: -2px; } .t-pct { width: 34px; color: #dddddd; } +.t-mem > .tm { display: block; width: 100%; } +.t-amt { width: 76px; text-align: right; color: #cccccc; } +.t-disks { overflow-y: auto; display: flex; flex-direction: column; gap: 6px; } +.t-disk .t-row + .t-row { margin-top: -1px; } + +/* net */ +.t-net { display: flex; gap: 10px; min-width: 0; flex: 1; min-height: 0; } +.t-net-graphs { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 140px; } +.t-net-g { position: relative; flex: 1; min-height: 0; display: flex; } .t-scale { position: absolute; left: 2px; top: 0; font-size: 11px; color: #6b7280; z-index: 1; } +.t-scale-b { top: auto; bottom: 0; } +.t-net-nums { width: 300px; flex-shrink: 0; border: 1px solid #303a4a; border-radius: 3px; padding: 4px 8px; } +.t-dl { color: #e050d0; } .t-ul { color: #50a0ff; } .t-gap { height: 6px; } .t-up-label { text-align: right; } +.t-if { color: #dddddd; } + +/* proc */ +.t-proc { display: flex; flex-direction: column; min-height: 0; flex: 1; } +.t-filter { background: #0a0d14; border: 1px solid #303a4a; border-radius: 3px; color: #dddddd; font-family: var(--mono); font-size: 12px; padding: 3px 6px; margin-bottom: 4px; } +.t-thead, .t-prow { display: grid; grid-template-columns: 58px 116px minmax(0, 1fr) 54px 58px 50px 56px 42px; gap: 7px; align-items: center; white-space: nowrap; } +.t-thead { padding-right: 8px; } /* the rows scroll under a scrollbar; keep the headings over their columns */ +.t-thead { color: #7b8496; font-size: 12px; border-bottom: 1px solid #23293a; padding-bottom: 2px; } +.t-rows { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; } +.t-prow { cursor: pointer; } .t-prow:hover { background: #12161f; } .t-prow.on { background: #1b2230; } +.t-prow .c-name { color: #00d35c; overflow: hidden; text-overflow: ellipsis; } +.t-prow .c-cmd { color: #9aa4b2; overflow: hidden; text-overflow: ellipsis; } +.t-prow .c-user { color: #cccccc; overflow: hidden; text-overflow: ellipsis; } +.c-pid, .c-thr, .c-mem, .c-cpu { text-align: right; } .t-thead .c-pid, .t-thead .c-name, .t-thead .c-cmd, .t-thead .c-user { text-align: left; } +.c-g { color: #6b7280; text-align: right; overflow: hidden; } +.t-foot { display: flex; align-items: center; gap: 8px; border-top: 1px solid #23293a; padding-top: 3px; margin-top: 2px; font-size: 12px; } +.t-foot button { background: none; border: 1px solid #303a4a; border-radius: 3px; color: #cccccc; font-family: var(--mono); font-size: 12px; padding: 1px 6px; cursor: pointer; } +.t-foot button:hover { border-color: #ee4b4b; color: #ee4b4b; } .t-count { color: #7b8496; } +.t-sorts { display: inline-flex; gap: 2px; } + +@media (max-width: 1100px) { + .top { grid-template-columns: 1fr 1fr; grid-template-rows: minmax(230px, auto) minmax(220px, auto) minmax(200px, auto) minmax(420px, 1fr); } + .top-mem, .top-disks { min-height: 220px; } + .top-cpu { grid-column: 1 / -1; grid-row: 1; } .top-mem { grid-column: 1; grid-row: 2; } .top-disks { grid-column: 2; grid-row: 2; } + .top-net { grid-column: 1 / -1; grid-row: 3; } .top-proc { grid-column: 1 / -1; grid-row: 4; } +} +@media (max-width: 800px) { + .top { display: flex; flex-direction: column; font-size: 12px; } + .top > .tbox { flex: 0 0 auto; } + .t-cpu { flex-direction: column; } .t-cpu-graph { height: 130px; flex: 0 0 130px; } .t-cores { width: auto; max-height: 40vh; } + .t-net { flex-direction: column; } .t-net-nums { width: auto; } .t-net-graphs { flex: 0 0 130px; } + .top-proc { height: 70vh; min-height: 420px; } + .t-thead, .t-prow { grid-template-columns: 52px 1fr 46px 46px 40px; } + .t-thead .c-cmd, .t-prow .c-cmd, .t-thead .c-thr, .t-prow .c-thr, .t-thead .c-g, .t-prow .c-g { display: none; } +}