From 668e7166017e084c7285442344719e5480fc10f2 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Mon, 21 Sep 2026 10:12:45 -0700 Subject: [PATCH] Top wears the skin; the refresh rate reaches every machine; bug bash 4 The process table's columns fit the box they are in, the rate control saves, curves are timed on the viewer's clock and keep to the stride, the power icon is drawn, and a young history draws in from the right instead of being stretched. Co-Authored-By: Claude Fable 5.1 --- BUGS.md | 82 +++++++++++++++++++++ SENSORS.md | 12 ++- internal/api/server.go | 73 ++++++++++++++++++ internal/api/status_sensors.go | 13 ++-- internal/metrics/fold_test.go | 30 ++++++++ internal/metrics/metrics.go | 22 ++++-- internal/store/store.go | 1 + web/src/Dashboard.tsx | 13 +++- web/src/Power.tsx | 5 +- web/src/Top.tsx | 130 ++++++++++++++++++++++----------- web/src/Updates.tsx | 2 +- web/src/prefs.ts | 9 +++ web/src/styles.css | 109 +++++++++++++++------------ 13 files changed, 394 insertions(+), 107 deletions(-) diff --git a/BUGS.md b/BUGS.md index 8b4bdcd..b61a82e 100644 --- a/BUGS.md +++ b/BUGS.md @@ -415,3 +415,85 @@ Found while building them, ✅ fixed: and cron rechecked on both sizes. - The Duplicate dialog and the stack page under it shared a React key, so the dialog multiplied. An undeployed stack's dot was green ("0 of 0 running"); now neutral. + +# Bug bash 4 — 2026-09-21: the Top tab, and the refresh rate it didn't follow + +Started from Mitchell's first look at Top on his own machine (Firefox on Windows, a window +1392 CSS px wide — none of which the tab had been drawn for): "Command" printed over +"Threads", the rate in Settings ignored, the symbol beside *logout* a box of hex digits; +and make it follow the skins. Method as before, plus two things new: **Firefox** (Playwright's +build; the GTK libraries it wants are unpacked from their `.deb`s into +`~/.cache/ms-playwright/fflibs` and given to it as `LD_LIBRARY_PATH`, with +`PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=1` — nothing is installed on `core`), and a layout +checker that measures instead of looking — every heading against its neighbour and its own +text, every row against its box, every border label, at eight window sizes from 900 to 1920. +Run first against the live v83 it reproduced the screenshot to the pixel ("the Command column +is 11px wide" at 1392); run after, nothing, in both browsers. + +## Bugs — all ✅ fixed and verified by the script that found them + +1. ✅ **Between about 1280 and 1500 px the Command column collapsed to a letter** and its + heading ran under "Threads:" (itself 4 px too narrow for its own text at every size). The + table's fixed columns were sized for one window. The process box now measures *itself* + (a container query — it is a third of a wide window and all of a narrow one, so the + window's width says nothing) and drops the curve, then Threads, then Command, as it + narrows; the box also gets half the width rather than 44 %, and no heading can spill + into the next. Stacked layout now starts at 1280, not 1100. +2. ✅ **The refresh rate reached a machine only through its Dashboard page.** The sampler on + each machine was told the rate by an open Dashboard and nothing else — so with the rate + at 250 ms, `grid`, `wyse` and the Mac were still sampling at 1 s (measured, live), and + Top, Processes, Disks and every widget of theirs "ignored" the setting until somebody + opened that machine's Dashboard. The rate is now the fleet's: saved with the UI prefs on + the hub, which sets its own sampler and passes it to every machine (`PUT + /api/prefs/sampler`, remembered in each `state.json`), and says it again at start-up for + a node that restarted or joined since. Verified: Settings 2 s → 250 ms moves the sampler + with no Dashboard visit. +3. ✅ **Top read the process table at most once a second whatever the rate** — a clamp of + my own, exactly what `REFRESH` promises not to do. It reads at the rate: 20 reads in 5 s + at 250 ms, 10 at 500 ms. (59 frames a second with 390 rows at 250 ms, no stalls, both + browsers — the rows are only redrawn when the table is read.) +4. ✅ **Top's `− rate +` changed the rate for half a minute.** It set the browser's copy + and never saved it, so the next sync with the hub put it back. It saves — which is also + what moves the samplers — survives a reload, and each end is disabled at the limit. +5. ✅ **A viewer whose clock is wrong froze the curves.** New points were timed against the + machine's timestamps: a browser clock 7 minutes slow waited 7 minutes for its next point + (Top *and* the Dashboard); 7 fast, and it added one per tick. Timed on the browser's own + clock now. Verified with the page's clock shifted both ways: 5 points in 10 s at a 2 s + stride, both pages, both browsers. +6. ✅ **Every other point was being skipped.** "Has a stride passed?" was asked with `>=` + of a timer that fires a few ms early half the time — so at a 2 s rate and a 2 s stride a + curve gained 3 points in 10 s, not 5, and its time axis was quietly wrong. The same + question on the server kept every bucket one sample long (14 s points at a 12 s stride). + Both now allow half a tick of slack and keep to the stride's own beat; the server times + from the last point kept, so a stalled or sleeping machine just keeps the next sample it + gets. Unit-tested with jittered samples: 240 samples → exactly 40 points, 12 000 ± 100 ms + apart, each the mean of its own six. +7. ✅ **The power symbol was a typed `⏻`** (U+23FB, Unicode 9) — not in Windows' fonts, so + Firefox there drew the missing-glyph box. Drawn as an SVG like the other icons. Every + other symbol the console types was checked against the same risk: all older. +8. ✅ **A handful of samples was stretched into a bar chart.** A machine ten seconds into + its history filled the whole CPU box with five fat columns. The box is now always the + whole history window — a young curve draws in from the right, as btop's does, and a full + one fills it edge to edge, at any window (checked at 10 min and at 1 min). +9. ✅ **Idle curves vanished**: a core at 10 % rounded to no dots at all in its mini graph. + Anything above zero lights the bottom row, which is also btop's rule. + +## Skins + +✅ Top now wears the skin. The ground is the skin's terminal background and every curve and +meter is built from its sixteen terminal colours (chosen to be read on exactly that ground), +the rest from the usual variables — so the dark skins look like btop and the light ones like +btop on paper, and a skin arriving from another device repaints the page in place. Measured +in all thirteen: text ≥ 4.5 : 1, everything else ≥ 3 : 1, dots ≥ 2.2 : 1. Two failed — +process names in Latte (2.75) and Solarized Light (2.62), where a terminal green is too +pale for small type — and use `--ok`, the green each skin was already measured readable +with. + +## Checked and fine + +Sort by pid, name, mem and cpu, and reverse; filter by name, command, user and pid, with the +count showing matched/total and an empty result saying so; a selection that holds while the +table is re-read under it; terminate, on a throwaway `sleep`, asks by name and pid, ends it, +and the footer stops offering a process that is gone. Sampler values outside 100 ms–60 s are +refused, and a rate slower than the collector's 10 s samples at 10 s. All 14 pages at his +window size and on a phone, in both browsers: no errors, no overflow. diff --git a/SENSORS.md b/SENSORS.md index b55d264..1d16ceb 100644 --- a/SENSORS.md +++ b/SENSORS.md @@ -206,7 +206,17 @@ with their name set into the border, curves drawn as dots that climb green → y 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.) +Its colours are the active skin's: the terminal background for the ground, the skin's sixteen +terminal colours for the curves and meters. (The look is btop's, `web/src/Top.tsx` is ours; +btop itself is Apache-2.0.) + +## How often a machine samples + +The console's refresh rate (Settings, or `− rate +` on Top) is also the fleet's sampling rate. +It is saved with the UI prefs on the hub, which sets its own sampler and passes the rate to +every machine it manages — `PUT /api/prefs/sampler {"ms": …}`, 100 ms to 60 s, sampled no +slower than the collector's 10 s — and repeats it at start-up. `GET` answers `{ms, min_ms, +max_ms}`. Before 2026-09-21 only an open Dashboard page told a machine its rate. ## How far back a history reaches diff --git a/internal/api/server.go b/internal/api/server.go index 24647a1..52bc5c2 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -98,6 +98,24 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me if histSec > 0 { col.SetWindow(time.Duration(histSec) * time.Second) } + // The sampler follows the console's refresh rate. A machine remembers what it was last told; + // the hub reads the rate from the saved UI prefs and tells everyone again, because a node + // that restarted, or was added since, would otherwise sample at its default until somebody + // happened to open its Dashboard. + var samplerMs int + var uiRaw json.RawMessage + st.View(func(x *store.State) { samplerMs, uiRaw = x.SamplerMs, x.UI }) + if samplerMs > 0 { + col.SetInterval(time.Duration(samplerMs) * time.Millisecond) + } + if nm != nil { + var ui struct { + RefreshMs float64 `json:"refreshMs"` + } + if json.Unmarshal(uiRaw, &ui) == nil && ui.RefreshMs >= 100 { + go s.setSampler(int(ui.RefreshMs), true) + } + } // A node that restarted, or was added since, has its own idea of the window: tell them all. if histSec > 0 && nm != nil { go s.pushHistoryWindow(histSec) @@ -223,6 +241,8 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api") pprof.Index(w, r) })) + m.HandleFunc("GET /api/prefs/sampler", s.auth(s.getSampler)) + m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler)) m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow)) m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow)) m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs)) @@ -1895,9 +1915,62 @@ func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) { jsonErr(w, 500, err.Error()) return } + if p.RefreshMs != nil { // the refresh rate is also how often every machine samples + go s.setSampler(int(*p.RefreshMs), true) + } w.WriteHeader(204) } +// ---- how often the machine samples ---- +// +// One rate for the fleet, the console's refresh rate: set in Settings (or the Top tab), saved +// with the UI prefs on the hub, and passed by the hub to every machine. Before this the +// sampler was told only by an open Dashboard page, so a machine whose Dashboard nobody had +// opened since the rate changed kept sampling at the old one, and every other page of it — +// Top, Processes, Disks, widgets — "ignored" the setting. + +// setSampler applies a rate here and, from a hub, everywhere. The collector's own limits are +// 100 ms to 10 s; a slower console rate samples at 10 s. +func (s *Server) setSampler(ms int, fanOut bool) { + lo, hi := int(metrics.MinInterval.Milliseconds()), int(metrics.MaxInterval.Milliseconds()) + if ms < lo { + ms = lo + } + if ms > hi { + ms = hi + } + s.metrics.SetInterval(time.Duration(ms) * time.Millisecond) + _ = s.store.Update(func(st *store.State) { st.SamplerMs = ms }) + if !fanOut || s.nodes == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + body, _ := json.Marshal(map[string]int{"ms": ms}) + hdr := http.Header{"X-Hamlab-From-Hub": []string{"1"}, "Content-Type": []string{"application/json"}} + for _, name := range s.nodes.Names() { + if code, _, err := s.nodes.Do(ctx, name, "PUT", "/api/prefs/sampler", body, hdr); err != nil || code >= 400 { + log.Printf("sampler: %s did not take %d ms (%d %v)", name, ms, code, err) + } + } +} + +func (s *Server) getSampler(w http.ResponseWriter, _ *http.Request) { + jsonOK(w, 200, map[string]any{"ms": s.metrics.Interval().Milliseconds(), "min_ms": metrics.MinInterval.Milliseconds(), "max_ms": metrics.MaxInterval.Milliseconds()}) +} + +func (s *Server) putSampler(w http.ResponseWriter, r *http.Request) { + var body struct { + Ms int `json:"ms"` + } + if json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body) != nil || body.Ms < 100 || body.Ms > 60000 { + jsonErr(w, 400, "expected {\"ms\": 100…60000}") + return + } + s.setSampler(body.Ms, r.Header.Get("X-Hamlab-From-Hub") == "") + s.getSampler(w, r) +} + // ---- how far back the graphs reach ---- // // One window for the whole fleet: the hub keeps it and pushes it to every machine, so a diff --git a/internal/api/status_sensors.go b/internal/api/status_sensors.go index 2710690..d236539 100644 --- a/internal/api/status_sensors.go +++ b/internal/api/status_sensors.go @@ -37,9 +37,9 @@ const statusHistory = 300 // A point being averaged, the same idea as the sampler's: these sources are read every 15 to // 300 seconds, so a wide window keeps one point per stride rather than one per read. type bucket struct { - start int64 - sum float64 - n float64 + last int64 // when this sensor's last point was kept + sum float64 + n float64 } const ( @@ -83,15 +83,16 @@ func (p *statusSensors) loop(name string, every time.Duration, read func(context } b := p.acc[x.ID] if b == nil { - b = &bucket{start: x.T} + b = &bucket{} p.acc[x.ID] = b } b.sum, b.n = b.sum+v, b.n+1 - if x.T-b.start < stride && b.n > 1 { + // due a stride after the last point kept, with half a read of slack for a late timer + if b.last != 0 && x.T-b.last < stride-every.Milliseconds()/2 { continue // still filling this point } h.T, h.V = append(h.T, x.T), append(h.V, b.sum/b.n) - *b = bucket{start: x.T + 1} + *b = bucket{last: x.T} if len(h.T) > statusHistory { h.T, h.V = h.T[len(h.T)-statusHistory:], h.V[len(h.V)-statusHistory:] } diff --git a/internal/metrics/fold_test.go b/internal/metrics/fold_test.go index b099466..415445f 100644 --- a/internal/metrics/fold_test.go +++ b/internal/metrics/fold_test.go @@ -67,3 +67,33 @@ func TestStrideAndTrim(t *testing.T) { t.Errorf("a window past the maximum is the maximum, got %v", c.Window()) } } + +// Points are one stride apart — not a stride and a sample — and jitter doesn't change that. +func TestKeepSpacing(t *testing.T) { + c := &Collector{interval: 2 * time.Second, window: time.Hour} // stride 12 s: six samples a point + at := int64(1_000_000) + for i := 0; i < 6*40; i++ { + jitter := int64((i*37)%41 - 20) // ±20 ms, deterministic + c.keep(Sample{T: at + jitter, CPU: float64(i % 6)}) + at += 2000 + } + if len(c.ring) != 40 { + t.Fatalf("240 samples at six a point are 40 points, got %d", len(c.ring)) + } + for i := 1; i < len(c.ring); i++ { + if gap := c.ring[i].T - c.ring[i-1].T; gap < 11900 || gap > 12100 { + t.Fatalf("point %d is %d ms after the one before; a stride is 12000", i, gap) + } + } + if c.ring[3].CPU != 2.5 { + t.Errorf("each point is the mean of its own six samples (0…5 → 2.5), got %v", c.ring[3].CPU) + } + // and when the stride is the interval, every sample is a point + c = &Collector{interval: 2 * time.Second, window: DefaultWindow} + for i := 0; i < 50; i++ { + c.keep(Sample{T: int64(i)*2000 + int64(i%3) - 1}) + } + if len(c.ring) != 50 { + t.Errorf("at the default window every sample is kept, got %d of 50", len(c.ring)) + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index f8ea429..f0cf616 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -32,15 +32,15 @@ const ( DefaultInterval = 2 * time.Second MinInterval = 100 * time.Millisecond MaxInterval = 10 * time.Second - History = 300 // points kept per curve; the window below sets how far apart they are + History = 300 // points kept per curve; the window below sets how far apart they are // How far back the kept history reaches. The point count is fixed, so this sets the spacing: // at a minute every sample is its own point, at a day each point is the average of ~5 minutes. DefaultWindow = 10 * time.Minute MinWindow = 1 * time.Minute MaxWindow = 24 * time.Hour - fsEvery = 30 * time.Second // filesystem usage changes slowly and statfs on network mounts isn't free - upsEvery = 10 * time.Second // UPS daemons are an exec each; don't hammer them at fast intervals - sectorSize = 512 // /proc/diskstats always counts 512-byte sectors + fsEvery = 30 * time.Second // filesystem usage changes slowly and statfs on network mounts isn't free + upsEvery = 10 * time.Second // UPS daemons are an exec each; don't hammer them at fast intervals + sectorSize = 512 // /proc/diskstats always counts 512-byte sectors ) type Mem struct { @@ -146,6 +146,7 @@ type Collector struct { ring []Sample // the kept history: one point per stride, each the average of its bucket last Sample // the newest raw sample, whatever the stride bucket []Sample // samples since the last point was kept + lastKept int64 // when that was, unix ms interval time.Duration window time.Duration wake chan struct{} // nudges Run when the interval changes @@ -271,9 +272,20 @@ func (c *Collector) stride() time.Duration { // two-second instant happened to land on the mark. func (c *Collector) keep(s Sample) { c.bucket = append(c.bucket, s) - if s.T-c.bucket[0].T < c.stride().Milliseconds() && len(c.bucket) > 1 { + // A point is due a stride after the last one (for the first, a stride after the interval its + // first sample stands for). Half an interval of slack, because a timer never fires exactly on + // time: without it a tick that lands 5 ms early is "not yet", and every point runs one sample + // long. Measured from the last point kept, not by counting samples, so a machine that stalled + // or slept simply keeps the next sample it gets. + iv := c.interval.Milliseconds() + since := c.lastKept + if since == 0 { + since = c.bucket[0].T - iv + } + if s.T-since < c.stride().Milliseconds()-iv/2 { return } + c.lastKept = s.T c.ring = append(c.ring, fold(c.bucket)) c.bucket = c.bucket[:0] c.trim() diff --git a/internal/store/store.go b/internal/store/store.go index 442ee11..877a847 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -22,6 +22,7 @@ type State struct { Dashboard json.RawMessage `json:"dashboard,omitempty"` // UI layout prefs, opaque to the server UI json.RawMessage `json:"ui,omitempty"` // theme etc., opaque to the server Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go) + SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one } diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index 65f061f..7be485f 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -2,7 +2,7 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { api, DashPrefs, Sample, Series, Temp } from './api' import Sparkline from './Sparkline' import { Loading, Spinner, askYes, toast } from './ui' -import { fmtMs, fmtWindowSec, strideMs, useHistorySec, useRefreshMs } from './prefs' +import { fmtMs, fmtWindowSec, nextPoint, strideMs, useHistorySec, useRefreshMs } from './prefs' import { fetchCatalog, fetchHistories, useSensorStream } from './sensors' import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter' import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets' @@ -314,11 +314,13 @@ export default function Dashboard() { const catalog = useRef(new Map()) const refetching = useRef(false) const binN = useRef(1) // values averaged into the point currently being built + const lastPush = useRef(0) // when that point was started, by this browser's clock const stock = useSensorStream(MONITOR_SHAPE, { full: false, everyMs: refreshMs }) const seed = useCallback(async () => { try { catalog.current = new Map((await fetchCatalog()).map((x) => [x.id, x])) hist.current = seriesOf(await fetchHistories(MONITOR_HISTORY), catalog.current) + lastPush.current = Date.now(); binN.current = 1 setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, []) @@ -333,9 +335,12 @@ export default function Dashboard() { const s = sampleOf(v, catalog.current) const h = hist.current if (h) { - // the point being built keeps the time it started at, so the next one is a whole stride later - const last = h.t[h.t.length - 1] ?? 0 - const fresh = h.t.length === 0 || s.t - last >= strideMs(historySec, refreshMs) + // The stride is timed on this clock alone (lastPush), never against the history's own + // timestamps: those are the machine's, and a viewer whose clock runs minutes slow would + // otherwise wait those minutes before its curves gained a point. + const np = nextPoint(s.t, h.t.length === 0 ? 0 : lastPush.current, strideMs(historySec, refreshMs), refreshMs) + const fresh = np.fresh + lastPush.current = np.start binN.current = fresh ? 1 : binN.current + 1 if (fresh) pushPoint(h.t, s.t, true, 1) const n = h.t.length diff --git a/web/src/Power.tsx b/web/src/Power.tsx index d3ae9d0..f67e6e4 100644 --- a/web/src/Power.tsx +++ b/web/src/Power.tsx @@ -20,7 +20,10 @@ export default function Power({ host, label, rebootNeeded }: { host: string; lab return ( <> - + {open && (
!sent && setOpen(false)}>
e.stopPropagation()}> diff --git a/web/src/Top.tsx b/web/src/Top.tsx index 73181d4..2c43f4f 100644 --- a/web/src/Top.tsx +++ b/web/src/Top.tsx @@ -3,33 +3,60 @@ // 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 { createContext, memo, useCallback, useContext, 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 { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs' import { Loading, askYes, toast } from './ui' +import { Theme, currentThemeId, themeById } from './themes' +import { savePrefs } from './accents' 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 +// ---- the palette: the skin's own terminal colours ---- +// Every skin carries sixteen terminal colours chosen to be read on its terminal ground, which +// is exactly what this page is: so the ground is the skin's --term-bg and every curve and +// meter is built from its green, yellow, red, blue, magenta and cyan. Dark skins come out +// looking like btop; the light ones like btop on paper. +type GradName = 'cpu' | 'used' | 'avail' | 'cached' | 'free' | 'down' | 'up' | 'temp' | 'io' +type Palette = Record & { vars: Record } +function paletteOf(t: Theme): Palette { + const x = t.xterm as Record + const c = (k: string, fb: string) => x[k] || fb + const green = c('green', '#00d35c'), yellow = c('yellow', '#c8e000'), red = c('red', '#ff3c3c'), blue = c('blue', '#4c8cff'), magenta = c('magenta', '#c850ff'), cyan = c('cyan', '#50d0ff') + return { + cpu: [green, yellow, red], + used: [red, c('brightRed', red), yellow], + avail: [yellow, c('brightYellow', yellow), c('brightYellow', yellow)], + cached: [blue, c('brightBlue', blue), cyan], + free: [green, c('brightGreen', green), c('brightGreen', green)], + down: [magenta, c('brightMagenta', magenta), c('brightMagenta', magenta)], + up: [blue, c('brightBlue', blue), cyan], + temp: [blue, c('brightBlue', blue), cyan], + io: [yellow, c('brightYellow', yellow), red], + vars: { '--t-green': green, '--t-blue': blue, '--t-magenta': magenta, '--t-red': red }, + } +} +function usePalette(): Palette { + const [pal, setPal] = useState(() => paletteOf(themeById(currentThemeId()))) + useEffect(() => { + const h = (e: Event) => setPal(paletteOf((e as CustomEvent).detail)) + window.addEventListener('hamlab:theme', h) + return () => window.removeEventListener('hamlab:theme', h) + }, []) + return pal +} +const Pal = createContext(paletteOf(themeById('dark'))) // mix(a, b, t) on hex, so a dot's colour can fall anywhere along a three-stop gradient +function rgbOf(h: string): number[] { + if (h.startsWith('#')) { const x = h.length === 4 ? h.replace(/#(.)(.)(.)/, '#$1$1$2$2$3$3') : h; return [0, 1, 2].map((i) => parseInt(x.slice(1 + i * 2, 3 + i * 2), 16)) } + const m = h.match(/[\d.]+/g); return m ? m.slice(0, 3).map(Number) : [128, 128, 128] +} 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)) + const x = rgbOf(a), y = rgbOf(b) + const c = [0, 1, 2].map((i) => Math.round(x[i] + (y[i] - x[i]) * t)) return `rgb(${c[0]},${c[1]},${c[2]})` } function at(g: readonly string[], t: number): string { @@ -40,9 +67,10 @@ function at(g: readonly string[], t: number): string { // ---- 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 }) { +function Graph({ values, tick, cap, max, grad, flip = false }: { values: number[]; tick: number; cap: number; max?: number; grad: GradName; flip?: boolean }) { const ref = useRef(null) const box = useRef(null) + const pal = useContext(Pal) const [[w, height], setSize] = useState([0, 0]) useEffect(() => { if (!box.current) return @@ -59,14 +87,21 @@ function Graph({ values, tick, max, grad, flip = false }: { values: number[]; ti 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))]) + // The box is the whole window, always: `cap` points wide (what the window holds at this + // rate) whatever the box measures, so a full history fills it edge to edge and a machine + // that has only just started draws in from the right, as btop does — a handful of samples + // is not stretched into a bar chart. + const n = Math.min(values.length, cap) + const seen: number[] = [] + for (let c = 0; c < cols; c++) { + const i = Math.floor((c * cap) / cols) - (cap - n) + if (i >= 0) seen.push(values[values.length - n + i]) + } const top = max ?? Math.max(1, ...seen) - const stops = GRAD[grad] + const stops = pal[grad] for (let i = 0; i < seen.length; i++) { - const lit = Math.round((Math.max(0, seen[i]) / top) * rows) + // anything above nothing lights the bottom row: the curve keeps a baseline, as btop's does + const lit = seen[i] > 0 ? Math.max(1, Math.round((seen[i] / top) * rows)) : 0 const x = w - (seen.length - i) * step for (let r = 0; r < lit; r++) { const t = rows > 1 ? r / (rows - 1) : 1 @@ -75,14 +110,14 @@ function Graph({ values, tick, max, grad, flip = false }: { values: number[]; ti g.fillRect(x, y, 2, 2) } } - }, [values, tick, w, height, max, grad, flip]) // tick: the curve is appended to in place - return
+ }, [values, tick, cap, w, height, max, grad, flip, pal]) // 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] + const g = useContext(Pal)[grad] return ( @@ -146,7 +181,7 @@ function braille(values: number[], cells = 7, max = 100): string { 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))) + const lit = v > 0 ? Math.max(1, Math.min(4, Math.round((v / top) * 4))) : 0 for (let r = 0; r < lit; r++) bitsOn |= BR[half * 4 + (3 - r)] } out += String.fromCharCode(0x2800 + bitsOn) @@ -166,18 +201,20 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec 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 + for (const h of hs) m.set(h.id, h.v.slice(-MAX)) + // The stride is timed on this clock only. The history's own timestamps are the machine's: + // measured against them, a viewer whose clock runs minutes slow would wait those minutes + // before its curves gained a single new point. + cur.current = m; lastT.current = Date.now(); binN.current = 1 bump((x) => x + 1) } catch { /* the curves fill in live instead */ } }, []) - useEffect(() => { seed() }, [seed, resumed]) + useEffect(() => { seed() }, [seed, resumed, historySec]) // a new window is a new spacing: start from what the machine now keeps useEffect(() => { if (!live || !t) return - const fresh = lastT.current === 0 || t - lastT.current >= strideMs(historySec, refreshMs) + const { fresh, start } = nextPoint(t, lastT.current, strideMs(historySec, refreshMs), refreshMs) binN.current = fresh ? 1 : binN.current + 1 - if (fresh) lastT.current = t + lastT.current = start const n = binN.current for (const [id, v] of Object.entries(live)) { if (typeof v !== 'number') continue @@ -220,10 +257,17 @@ export default function Top() { 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)) + const i = window.setInterval(poll, refreshMs) // exactly the rate, like every other page: nothing is throttled behind his back return () => { stop = true; window.clearInterval(i) } }, [refreshMs]) + // the same global rate Settings sets: saved on the hub, so it sticks and every machine's sampler follows + const rateIdx = (() => { const i = REFRESH_OPTIONS.findIndex((x) => x >= refreshMs); return i < 0 ? REFRESH_OPTIONS.length - 1 : i })() + const setRate = (i: number) => { setRefreshMs(REFRESH_OPTIONS[Math.max(0, Math.min(REFRESH_OPTIONS.length - 1, i))]); savePrefs() } + const pal = usePalette() + // how many points the history window holds at this rate: the width of every graph + const cap = Math.max(2, Math.min(MAX, Math.round((historySec * 1000) / strideMs(historySec, refreshMs)))) + const [sort, setSort] = useState<'cpu' | 'mem' | 'pid' | 'name'>('cpu') const [rev, setRev] = useState(false) const [filter, setFilter] = useState('') @@ -257,7 +301,7 @@ export default function Top() { 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
+ if (!s && !pt) return
const cpu = s?.cpu ?? pt?.cpu ?? 0 const cores = (s?.cores?.length ? s.cores : pt?.cores) ?? [] @@ -279,18 +323,19 @@ export default function Top() { const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0) return ( -
+ +
{clock.toLocaleTimeString([], { hour12: false })} - + {fmtMs(refreshMs)} - + }>
- +
up {upfor(uptime)}
@@ -345,10 +390,10 @@ export default function Top() {
{rate(Math.max(1, ...(curves.get(`net.${segOf(iface.iface)}.rx`) ?? [0])))} - +
- + {rate(Math.max(1, ...(curves.get(`net.${segOf(iface.iface)}.tx`) ?? [0])))}
@@ -391,6 +436,7 @@ export default function Top() {
+ ) } diff --git a/web/src/Updates.tsx b/web/src/Updates.tsx index c9f426b..7de6778 100644 --- a/web/src/Updates.tsx +++ b/web/src/Updates.tsx @@ -66,7 +66,7 @@ export default function Updates() { {st.manager && !st.sudo &&
Read-only: refreshing and upgrading need root. Run Ham Lab as root, or give its user passwordless sudo or doas.
} {st.reboot_needed && (
- Reboot required{st.reboot_pkgs.length > 0 && <> · {st.reboot_pkgs.join(', ')}} — use the ⏻ button in the header. + Reboot required{st.reboot_pkgs.length > 0 && <> · {st.reboot_pkgs.join(', ')}} — use the power button in the header.
)} {err &&
{err}
} diff --git a/web/src/prefs.ts b/web/src/prefs.ts index 796789d..bb802bc 100644 --- a/web/src/prefs.ts +++ b/web/src/prefs.ts @@ -59,6 +59,15 @@ export function strideMs(windowSec: number, refreshMs: number): number { return Math.max(refreshMs, Math.round((windowSec * 1000) / HISTORY_POINTS)) } +// Is it time for a curve's next point? `last` is when the current one was started (this +// browser's clock; the caller keeps it). Half a tick of slack, because ticks never land exactly +// on time: without it a tick 5 ms early is "not yet" and every other point is skipped. The +// returned start keeps to the stride's own beat rather than drifting with each late tick. +export function nextPoint(now: number, last: number, stride: number, tickMs: number): { fresh: boolean; start: number } { + if (last === 0 || now - last >= stride - tickMs / 2) return { fresh: true, start: last === 0 || now - last > 2 * stride ? now : last + stride } + return { fresh: false, start: last } +} + export function fmtWindowSec(sec: number): string { if (sec >= 3600) { const h = sec / 3600; return `${Number.isInteger(h) ? h : h.toFixed(1)} hour${h === 1 ? '' : 's'}` } if (sec >= 60) { const m = sec / 60; return `${Number.isInteger(m) ? m : m.toFixed(1)} min` } diff --git a/web/src/styles.css b/web/src/styles.css index 48c1537..627979d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -536,82 +536,99 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, .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; } + The layout and the dots are btop's; the colours are the skin's. The ground is the skin's + terminal background and everything on it comes from the skin's variables and its sixteen + terminal colours (Top.tsx hands the few it needs over as --t-*), so switching skin reskins + this page like any other. Fixed-width everywhere, because that is the look. */ +.top { position: absolute; inset: 0; background: var(--term-bg); color: var(--fg); font-family: var(--mono); font-size: 13px; line-height: 1.35; + padding: 6px; display: grid; gap: 4px; grid-template-columns: 1fr 1fr 2.2fr; 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 { position: relative; border: 1px solid var(--border); 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; } +.tbox-name, .tbox-right { position: absolute; top: -9px; background: var(--term-bg); padding: 0 4px; font-size: 12px; white-space: nowrap; z-index: 1; } +.tbox-name { left: 8px; color: var(--fg); } .tbox-name b { color: var(--accent); font-weight: 400; } +.tbox-right { right: 8px; color: var(--muted); display: flex; align-items: center; gap: 8px; } +.tbox-right button { background: none; border: 0; color: var(--muted); font-family: var(--mono); font-size: 12px; padding: 0 3px; cursor: pointer; } +.tbox-right button:hover:not(:disabled), .tbox-right button.on { color: var(--accent); } .tbox-right button:disabled { opacity: .35; cursor: default; } +.t-clock { color: var(--fg); } .t-rate { display: inline-flex; align-items: center; gap: 2px; } +.t-dim { color: var(--muted); } .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; white-space: nowrap; } -.t-cores-head b { color: #dddddd; font-weight: 400; overflow: hidden; text-overflow: ellipsis; } +.t-up { color: var(--muted); font-size: 12px; padding-top: 2px; } +.t-cores { width: 310px; flex-shrink: 0; border: 1px solid var(--border); 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: var(--muted); font-size: 12px; white-space: nowrap; } +.t-cores-head b { color: var(--fg); font-weight: 400; overflow: hidden; text-overflow: ellipsis; } .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; } +.t-core-n { width: 30px; } .t-core-p { width: 34px; text-align: right; } +.t-core-g { flex: 1; color: var(--ok); 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: var(--t-blue); } +.t-load { display: flex; margin-top: 2px; } /* 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; } +.tm-t { background: var(--border); } /* 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-row { display: flex; align-items: center; gap: 6px; } .t-row b { font-weight: 400; } +.t-sub { color: var(--muted); font-size: 12px; } +.t-mem { margin-bottom: 4px; } .t-mem .t-sub { margin-top: -2px; } .t-pct { width: 34px; color: var(--fg); } .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; } +.t-amt { width: 76px; text-align: right; color: var(--fg); white-space: nowrap; } +.t-disks { overflow-y: auto; display: flex; flex-direction: column; gap: 6px; scrollbar-width: thin; } +.t-disk .t-row + .t-row { margin-top: -1px; } .t-disk b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* 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-net-g { position: relative; flex: 1; min-height: 0; display: flex; } .t-scale { position: absolute; left: 2px; top: 0; font-size: 11px; color: var(--muted); z-index: 1; background: var(--term-bg); padding-right: 3px; } .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; } +.t-net-nums { width: 300px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 3px; padding: 4px 8px; white-space: nowrap; overflow: hidden; } +.t-dl { color: var(--t-magenta); } .t-ul { color: var(--t-blue); } .t-gap { height: 6px; } .t-up-label { text-align: right; } +.t-if { color: var(--fg); } -/* proc */ +/* proc — the columns that fit are the columns shown: the box measures itself (it is a third of + a wide window and all of a narrow one, so the window's width says nothing) and drops the + curve, then the threads, then the command, rather than crushing Command to one letter */ +.top-proc { container: tproc / inline-size; } .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-filter { background: var(--bg); border: 1px solid var(--border); border-radius: 3px; color: var(--fg); 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) 62px 58px 50px 56px 42px; gap: 7px; align-items: center; white-space: nowrap; } +.t-thead > span, .t-prow > span { min-width: 0; overflow: hidden; } /* a heading never runs into its neighbour */ +.t-thead { padding-right: 8px; color: var(--muted); font-size: 12px; border-bottom: 1px solid var(--border); 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; } +.t-prow { cursor: pointer; } .t-prow:hover { background: var(--bg2); } .t-prow.on { background: var(--bg3); } +.t-prow .c-name { color: var(--ok); text-overflow: ellipsis; } /* --ok, not the terminal's green: it is the one each skin was measured readable with */ +.t-prow .c-cmd { color: var(--muted); text-overflow: ellipsis; } +.t-prow .c-user { 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; } +.c-g { color: var(--muted); text-align: right; } +@container tproc (max-width: 640px) { + .t-thead, .t-prow { grid-template-columns: 58px 116px minmax(0, 1fr) 62px 58px 50px 42px; } + .t-thead .c-g, .t-prow .c-g { display: none; } +} +@container tproc (max-width: 560px) { + .t-thead, .t-prow { grid-template-columns: 58px 110px minmax(0, 1fr) 58px 50px 42px; } + .t-thead .c-thr, .t-prow .c-thr { display: none; } +} +@container tproc (max-width: 470px) { + .t-thead, .t-prow { grid-template-columns: 52px minmax(0, 1fr) 52px 48px 40px; gap: 6px; } + .t-thead .c-cmd, .t-prow .c-cmd { display: none; } +} +.t-foot { display: flex; align-items: center; gap: 8px; border-top: 1px solid var(--border); padding-top: 3px; margin-top: 2px; font-size: 12px; } +.t-foot button { background: none; border: 1px solid var(--border); border-radius: 3px; color: var(--fg); font-family: var(--mono); font-size: 12px; padding: 1px 6px; cursor: pointer; } +.t-foot button:hover { border-color: var(--bad); color: var(--bad); } .t-count { color: var(--muted); } .t-sorts { display: inline-flex; gap: 2px; } -@media (max-width: 1100px) { +@media (max-width: 1280px) { .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; } @@ -623,6 +640,4 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, .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; } }