From 3943e08dabe684b409a3ac99c9371fbeb5df5cdb Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 13 Oct 2026 11:35:00 +0200 Subject: feat: system, gpu, disks and network move to the quickshell island The quickshell island's performance panel now shows CPU, RAM, temperatures, the GPU, disks and network, and warns from the status pill when something runs hot, fills up, or the public IP drifts from the pinned one. Two dashboards drawing the same numbers would drift apart, so the four widgets go, with the disks and public IP samplers. The island keeps writing ~/.cache/udt/pubip.txt in the same format. The freed space is left empty for the user to rearrange. Co-Authored-By: Claude Sonnet 5.5 --- DESIGN.md | 24 +-- README.md | 138 ++------------ bin/disks-sample.sh | 40 ---- bin/pubip-sample.sh | 48 ----- conky.conf.in | 2 +- dashboard.lua | 8 +- lib/card.lua | 145 +------------- lib/data.lua | 397 +-------------------------------------- test/fixtures/df_output | 7 - test/fixtures/ip_addr | 3 - test/fixtures/proc_cpuinfo | 12 -- test/fixtures/proc_cpuinfo_intel | 6 - test/fixtures/proc_meminfo | 7 - test/fixtures/proc_stat | 5 - test/fixtures/proc_stat_percore | 11 -- test/fixtures/temp1_input | 1 - test/test_data.lua | 263 -------------------------- widgets/disks.lua | 150 --------------- widgets/gpu.lua | 121 ------------ widgets/network.lua | 181 ------------------ widgets/system.lua | 198 ------------------- 21 files changed, 32 insertions(+), 1735 deletions(-) delete mode 100755 bin/disks-sample.sh delete mode 100755 bin/pubip-sample.sh delete mode 100644 test/fixtures/df_output delete mode 100644 test/fixtures/ip_addr delete mode 100644 test/fixtures/proc_cpuinfo delete mode 100644 test/fixtures/proc_cpuinfo_intel delete mode 100644 test/fixtures/proc_meminfo delete mode 100644 test/fixtures/proc_stat delete mode 100644 test/fixtures/proc_stat_percore delete mode 100644 test/fixtures/temp1_input delete mode 100644 widgets/disks.lua delete mode 100644 widgets/gpu.lua delete mode 100644 widgets/network.lua delete mode 100644 widgets/system.lua diff --git a/DESIGN.md b/DESIGN.md index b463c1f..cd7a8a5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -12,15 +12,13 @@ bottom: - **Header.** The title, or the glyph that stands in for it, sits top-left in the `label` colour. The card's **big number** sits at the right edge, on the same line, in `FONT_HEAVY`. Nothing else shares that line. A card whose data - lives entirely in its variable band drops the big number: `disks` has no - headline, the rings are the value. + lives entirely in its variable band drops the big number and lets that band be the value. - **Body.** Rows follow one pattern: **label left, value right** (`card.text` and `card.text_right`), so the eye runs down the right edge for numbers. A row's value takes the `threshold` colour when it is a measured quantity, and stays in `value` when it is not (a fan RPM, a total). - **Variable band.** One element may absorb the height left over, chosen per - card: the equaliser in `system`, the daylight arc in `weather`, the ring row - in `disks`. Or the type itself fills the cell. + card: the daylight arc in `weather`. Or the type itself fills the cell. Failure states keep the chrome and draw a notice in it ("no cache data", `--` for a missing sensor). An empty cell is indistinguishable from a crashed @@ -40,7 +38,7 @@ Everything else exists to keep those two readable at any cell size. of a width fit and a height budget. `card.fit_unit` measures candidate rows and returns the largest base size that fits the width, and a row that would collide pulls the size down. Bigger cell, bigger type instead of empty space. - The `system`, `gpu` and `cache` cards do this; a card that cannot afford the + The `cache` card does this; a card that cannot afford the measuring pass may key off width with a clamped factor. - **Roles.** `FONT_MONO` for labels and row text, `FONT_HEAVY` for the big number, `FONT_UI` for prose (a condition description, a notice). Sizes within @@ -54,14 +52,11 @@ One language for every measured quantity, so state is read rather than decoded: | State | Role | Example | |---|---|---| -| fine | `ok` | a cool sensor, an empty filesystem | -| busy | `warning` | a core under load, a filesystem past 25% | -| hot / full | `critical` | a sensor at its ceiling, a nearly full disk | - -- Proportions (CPU load, filesystem and cache usage) use **25% / 75%**. -- Temperatures use a **per-sensor pair**, because 70C is unremarkable on a CPU - and alarming on an NVMe. The pairs live beside the hwmon bindings in the - widget. +| fine | `ok` | a fresh cache, a current changelog | +| busy | `warning` | a cache past 25% | +| hot / full | `critical` | a cache past 75%, a stale changelog | + +- Proportions (cache usage) use **25% / 75%**. - `warning` is the palette's middle role. The board should show all three states in normal use, or the language is not earning its place. - The big value's colour follows what it is: `body` for a plain number, the @@ -72,8 +67,7 @@ One language for every measured quantity, so state is read rather than decoded: ## Glyphs Nerd Font glyphs stand in for labels where an icon reads faster than a word: -the disks rings put a distro / house / disk / network glyph at each ring's -centre, weather puts the condition glyph in its header, and every card title +weather puts the condition glyph in its header, and every card title carries one in `highlight` before its label (the codepoints live in `card.ICON`). A wrong-but-present codepoint draws a plausible neighbour rather than failing, so render a new glyph and look at it before trusting it. diff --git a/README.md b/README.md index b137121..ad6f930 100644 --- a/README.md +++ b/README.md @@ -63,45 +63,33 @@ in the source, so no real location appears in committed files. ## System widgets -Seven cards cover the machine itself: - -- **System**: CPU load as a per-core equaliser, the CPU and motherboard model - as two rows under the header, RAM used/total, and every temperature (CPU - package, CCD die, NVMe and two board sensors). -- **GPU**: Arc B580 package and VRAM temperatures, VRAM in use as a bar, and - fan speed. -- **Disks**: `/`, `/home`, `/data` and one NFS mount per server, as usage - rings, with free space and total size printed under each ring. Two servers - are configured, each with the same export mounted twice; the card shows one - mount per server so no number appears twice. +Three cards cover the machine itself: + - **Cache**: `~/.cache` total and its four largest subdirectories. -- **Network**: LAN and public address, and download/upload throughput as two - lines on one chart. See below. - **Slackware**: distribution version, package count, kernel, install age, and time since the last `slackpkg` ChangeLog update. See below. - **Updates**: nvtable's table of tracked packages behind upstream, scrolled with the mouse wheel. See below. -The disks and cache cards read a cache file that a sampler writes; they never -call `du` or `statfs` themselves. `statfs` on an unreachable NFS path blocks, -and the disks sampler exists so that a dead server costs a stale cache rather -than a frozen draw. Sampling is scheduled by conky itself: `conky.text` carries +CPU, RAM, temperatures, the GPU, disks, network and the public address used to +be cards here. They moved to the performance panel of the quickshell island +(`desktop/island/` in the quickshell repo), which also warns from the status +pill. The island keeps writing `~/.cache/udt/pubip.txt`. + +The cache card reads a cache file that a sampler writes; it never calls `du` +itself. Sampling is scheduled by conky itself: `conky.text` carries a threaded `${texeci}` per sampler, even though that block renders nothing under Cairo. ${texeci 900 ~/.config/conky/bin/weather-fetch.sh} - ${texeci 60 ~/.config/conky/bin/disks-sample.sh} ${texeci 900 ~/.config/conky/bin/cache-sample.sh} - ${texeci 1800 ~/.config/conky/bin/pubip-sample.sh} ${texeci 900 ~/.config/conky/bin/slackware-sample.sh} ${texeci 300 ~/.config/conky/bin/calendar-sample.sh} ${texeci 1800 ~/.config/conky/bin/immich-fetch.sh} ${texeci 600 ~/.config/conky/bin/updates-sample.sh} -The disks sampler runs once a minute; the cache, Slackware and public-IP -samplers run every 15, 15 and 30 minutes respectively, because `du -sh +The cache and Slackware samplers run every 15 minutes, because `du -sh ~/.cache` walks the tree and takes about 100ms warm, which is fine on a slow -cycle and unthinkable on the 2-second draw, and a residential public address -does not change often enough to warrant asking more frequently. The calendar +cycle and unthinkable on the 2-second draw. The calendar sampler runs every five minutes: khal costs about 200ms of Python startup, which rules out calling it from a draw, and the cache has to notice both a vdirsyncer run and the day rolling over. Tying them to @@ -109,9 +97,7 @@ conky means nothing samples while the dashboard is down. To run a sampler by hand: - ./bin/disks-sample.sh; echo "exit: $?" ./bin/cache-sample.sh; echo "exit: $?" - ./bin/pubip-sample.sh; echo "exit: $?" ./bin/slackware-sample.sh; echo "exit: $?" ./bin/calendar-sample.sh; echo "exit: $?" ./bin/updates-sample.sh; echo "exit: $?" @@ -119,38 +105,11 @@ To run a sampler by hand: All print nothing and exit 0 on success, and all write a temp file and rename it over the target, so the widget never reads a half-written file. -### Network - -The chart plots download and upload as two lines on **one shared vertical -scale**: scaled independently they would lie about their relative size, which -is the entire reason to draw them together. The window peak is printed at the -chart's top-left and the live rates at the bottom corners, each in its line's -colour. History is module state inside `widgets/network.lua`, one sample per -pixel column of the chart so it is exactly as wide as the card and never -interpolates; it does not survive a `restart.sh` or a conky restart, so the -chart starts empty and fills left to right. - -The rates come from the graphed interface's own byte counters under -`/sys/class/net`, read on every draw: two file reads, no subprocess. The LAN -address comes from `ip -4 addr show br0` and is memoized on first success, -retried while nil because a bridge may not be up yet when conky starts. The -public address comes from the cache file `bin/pubip-sample.sh` writes; once -that cache is older than four hours the card shows `--` instead, because an -address that may no longer be yours displayed with confidence is worse than a -dash. - -**`br0` is a bridge, not a physical interface**, and the `IFACE` constant at -the top of `widgets/network.lua` is the one edit that changes which interface -is graphed. VM-to-host traffic crosses a bridge without reaching the router, -so a local copy of that traffic appears in the chart. This carries over -unchanged from the previous text config and is accepted behaviour, not a bug. - ### Slackware The big top-right value is the time since `/var/lib/slackpkg/ChangeLog.txt` last changed, coloured `ok` under 24 hours, `warning` to a week, `critical` -beyond, the same three-role language the system, GPU, disks and cache cards -use. Below it: distribution version, package count, kernel, and install age +beyond, the same three-role language the cache card uses. Below it: distribution version, package count, kernel, and install age (the root filesystem's birth time, where the filesystem records one). ### Calendar @@ -236,43 +195,15 @@ at most every 30 minutes, detached so conky never waits on the network; its results show up on the next sample. `nvtable` is looked up in `~/bin` too, since conky launched by the compositor lacks the login shell's `PATH`. -### Host bindings - -These hwmon bindings, and the ceilings beside them, are specific to this machine -and will not port. They are written down because the alternative is -rediscovering them from a wrong reading. - -| chip | file | label | warning / critical | -|---|---|---|---| -| `k10temp` | `temp1_input` | CPU package | 75 / 90 | -| `k10temp` | `temp3_input` | CCD die | 75 / 90 | -| `nvme` | `temp1_input` | WD_BLACK SN850X | 60 / 70 | -| `xe` | `temp2_input` | Arc B580 package | 75 / 85 | -| `xe` | `temp3_input` | Arc B580 memory | 80 / 90 | -| `xe` | `fan1_input` | Arc B580 fan RPM | none | -| `gigabyte_wmi` | `temp2_input` | board | 60 / 70 | -| `gigabyte_wmi` | `temp3_input` | board | 60 / 70 | - -Chips are globbed by the contents of their hwmon `name` file, never by index: -indices drift across kernel and hardware reorders and a stale one silently -reports a different chip. `Tccd1` is read straight from sysfs rather than by -shelling out to `sensors`; same number, no subprocess. The `gigabyte_wmi` chip -exposes five unlabelled temperatures, two of which the old conky config showed; -they are labelled "board" rather than given a meaning they may not have. - ### One colour language across every card Anything with a comfortable range and an uncomfortable one is coloured by the same three roles, so a card reads without learning a new scheme: `ok` below the first threshold, `warning` between them, `critical` above the second. -Filesystems, cache usage and CPU load use 25% and 75%. Temperatures use a -per-sensor pair (the table above), because 70C is unremarkable on a CPU package -and alarming on an NVMe. The Slackware card's ChangeLog age uses the same +Cache usage uses 25% and 75%. The Slackware card's ChangeLog age uses the same three roles on an hour scale (24 / 168) instead of a percentage. These are -starting values, host-specific exactly as the -bindings are, and sit in one table per widget so they are easy to retune once -real numbers under load are known. +starting values, and sit in one table per widget so they are easy to retune. ## Changing the layout @@ -336,9 +267,9 @@ cell is left sparse; size it off the height alone and a short cell shrinks the text to a whisper. The robust way is a measured fit: `card.fit_unit()` returns the largest base size at which every row still fits the width, and the widget takes the smaller of that and its height budget. Larger cell, larger type; a row -that would collide pulls the size back down. The GPU, cache and system cards do +that would collide pulls the size back down. The cache card does this. A card that cannot afford the measuring pass can still key off width with -a clamp, `math.max(lo, math.min(hi, inner.w * k))`; the clock, weather and disks +a clamp, `math.max(lo, math.min(hi, inner.w * k))`; the clock and weather cards do. **Decide where the slack goes, and let one element absorb it.** The clock pins @@ -443,9 +374,6 @@ widget passes its own. The clock uses `FONT_CLOCK` (Oswald, OFL-1.1, under substitutes a default for a name it does not know, which looks exactly like the font "not applying". -**Sensors are globbed by hwmon `name`, never by index.** Indices drift across -kernel and hardware reorders, and a stale one silently reports a different chip. - **`${execi}` fires even though `conky.text` renders nothing.** Cairo output covers the text stream, so the `execi` that refreshes the weather cache is invisible, but it still runs on schedule. This was checked with a probe config @@ -475,40 +403,6 @@ half empty. Fill the cell instead: `card.fit_unit()` measures the rows and returns the largest size that fits the width, the widget takes the smaller of that and its height budget, and a row that would collide pulls the size down. -**A bare `df` would silently mis-read every disk figure.** The user's shell -aliases `df` to `df -h`, so `bin/disks-sample.sh` calls `/usr/bin/df -P -B1` by -absolute path. `-P` is the POSIX format, one line per filesystem with the -mountpoint last, and `-B1` is bytes so the parser never interprets a suffix. An -alias or a function would hand the parser `1.6G` where it expects an integer, -and every figure would be quietly wrong. - -**The disks card shows `df`'s available column, not `size - used`.** On this -machine's root filesystem the two differ by about 13GB of root-reserved -blocks, which exist on disk but are not available to any non-root user. A -card that computed free space as `size - used` would overstate it by exactly -that reserve. - -**The Arc B580 reports no utilisation through any stable interface.** The `xe` -driver exposes no `gpu_busy_percent` (unlike `amdgpu`) and `intel_gpu_top` -refuses the device. The GPU card therefore has no load bar; it shows what the -hardware actually reports rather than inventing a number. - -**VRAM in use comes from DRM fdinfo**, the per-client counters `gputop` reads: -`drm-resident-vram0` in `/proc//fdinfo/`, summed once per -`drm-client-id`. Only processes readable by conky's user are seen, so a GPU -client running as another user is not counted. The card's 12 GiB total is -hardcoded: xe puts no VRAM capacity in sysfs. - -**`/data` has no temperature.** It sits on a spinning disk with no hwmon chip; -reading its SMART temperature would need `smartctl` as root and would spin up a -sleeping disk on every poll. The NVMe composite temperature is freely readable -and is shown; `/data` simply has none. - -**Per-core counters are module-level in `widgets/system.lua`.** Load is a delta -between frames, so the counter has to outlive the draw; one created inside -`draw` would sample against nothing and report nil forever. The aggregate -counter is the same. - ## License GPLv2 only. See `LICENSE`. diff --git a/bin/disks-sample.sh b/bin/disks-sample.sh deleted file mode 100755 index 5291bcf..0000000 --- a/bin/disks-sample.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -# Sample filesystem usage into a cache file, for widgets/disks.lua. -# -# This exists so the draw hook never calls statfs on an NFS path. An -# unreachable server blocks that call, and blocking the Cairo draw freezes the -# whole dashboard; here it costs a stale cache and nothing else. -# -# One NFS mount per server: Library and Slackware are the same export on one -# server, shared and backup_danix the same on another, so showing all four -# printed every number twice. - -set -u - -CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt" -CACHE="$CACHE_DIR/disks.txt" - -MOUNTS=(/ /home /data /mnt/nfs/Library /mnt/nfs/shared) - -umask 077 -mkdir -p "$CACHE_DIR" - -TMP="$CACHE.tmp.$$" -trap 'rm -f "$TMP"' EXIT - -# /usr/bin/df by absolute path with -P -B1, deliberately: -# - the user's shell aliases df to `df -h`, and an alias or a function would -# hand the parser '1.6G' where it expects an integer -# - -P is the POSIX format, one line per filesystem, mountpoint last -# - -B1 is bytes, so the widget does the formatting and the parser never -# has to interpret a suffix -if ! /usr/bin/df -P -B1 "${MOUNTS[@]}" > "$TMP" 2>/dev/null; then - # A single unreachable mount must not discard the others: df still reports - # the ones it could stat, so keep the output if it has any data rows. - if [ "$(wc -l < "$TMP")" -lt 2 ]; then - echo "disks-sample: df produced nothing usable" >&2 - exit 1 - fi -fi - -mv -f "$TMP" "$CACHE" diff --git a/bin/pubip-sample.sh b/bin/pubip-sample.sh deleted file mode 100755 index c5100f8..0000000 --- a/bin/pubip-sample.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -# Fetch the public IP address into a cache file, for widgets/network.lua. -# -# Run from conky's ${execi} every 30 minutes. The draw hook must never make a -# network call: a hung request would block the Cairo draw and freeze the whole -# dashboard, and a residential address is stable for days anyway. -# -# Exits non-zero with a message on stderr when it cannot fetch, leaving any -# existing cache untouched rather than replacing a good address with an error -# page. - -set -u - -CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt" -CACHE="$CACHE_DIR/pubip.txt" - -umask 077 -mkdir -p "$CACHE_DIR" - -# The temp file sits in the same directory as the target: rename is only -# atomic within a filesystem, and the widget reads this on its own 2s cadence. -TMP="$CACHE.tmp.$$" -trap 'rm -f "$TMP"' EXIT - -# --max-time, not just --connect-timeout: a server that accepts the connection -# and then stalls would otherwise hold a curl process for as long as it likes. -if ! IP=$(curl -fsS --max-time 10 https://ipinfo.io/ip 2>/dev/null); then - echo "pubip-sample: fetch failed" >&2 - exit 1 -fi - -# Validate before writing. A captive portal, a rate-limit message and an error -# page all arrive with a 200 and would otherwise be written to the cache and -# drawn on the dashboard as though they were an address. -IP=$(echo "$IP" | tr -d '[:space:]') -if ! echo "$IP" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then - echo "pubip-sample: response is not an IPv4 address" >&2 - exit 1 -fi - -# The epoch travels with the address so the widget can refuse to show a stale -# one. -{ - echo "ip $IP" - echo "fetched $(date +%s)" -} > "$TMP" - -mv -f "$TMP" "$CACHE" diff --git a/conky.conf.in b/conky.conf.in index d12ffc1..bf7fedc 100644 --- a/conky.conf.in +++ b/conky.conf.in @@ -60,4 +60,4 @@ conky.config = { -- texeci, not execi: execi runs in conky's main loop and blocks the draw until -- the script exits. At 0.1s while visible, a 0.7s nvtable or an Immich -- download stalls the scroll; texeci runs each in its own thread. -conky.text = [[${texeci 900 ~/.config/conky/bin/weather-fetch.sh}${texeci 60 ~/.config/conky/bin/disks-sample.sh}${texeci 900 ~/.config/conky/bin/cache-sample.sh}${texeci 1800 ~/.config/conky/bin/pubip-sample.sh}${texeci 900 ~/.config/conky/bin/slackware-sample.sh}${texeci 300 ~/.config/conky/bin/calendar-sample.sh}${texeci 5 ~/.config/conky/bin/breaktimer-sample.sh}${texeci 1800 ~/.config/conky/bin/immich-fetch.sh}${texeci 600 ~/.config/conky/bin/updates-sample.sh}]] +conky.text = [[${texeci 900 ~/.config/conky/bin/weather-fetch.sh}${texeci 900 ~/.config/conky/bin/cache-sample.sh}${texeci 900 ~/.config/conky/bin/slackware-sample.sh}${texeci 300 ~/.config/conky/bin/calendar-sample.sh}${texeci 5 ~/.config/conky/bin/breaktimer-sample.sh}${texeci 1800 ~/.config/conky/bin/immich-fetch.sh}${texeci 600 ~/.config/conky/bin/updates-sample.sh}]] diff --git a/dashboard.lua b/dashboard.lua index 11ffb24..f0c34e6 100644 --- a/dashboard.lua +++ b/dashboard.lua @@ -22,17 +22,11 @@ local layout = { { widget = 'clock', col = 1, row = 1.5, w = 1.5, h = 5 }, -- Under the clock, same column. { widget = 'weather', col = 1, row = 7, w = 3, h = 5 }, - -- Left of the disks row, in the board's open middle column. + -- In the board's open middle column, left of immich. { widget = 'calendar', col = 2.5, row = 1, w = 3, h = 6 }, -- Clustered near weather and clock { widget = 'breaktimer', col = 4, row = 7, w = 2, h = 3 }, - { widget = 'system', col = 14, row = 4, w = 3, h = 5 }, - { widget = 'gpu', col = 12, row = 4, w = 2, h = 3 }, - { widget = 'disks', col = 12, row = 1, w = 5, h = 3 }, { widget = 'cache', col = 12, row = 7, w = 2, h = 3 }, - -- Under system, with slackware beside it. Provisional: there is no settled - -- arrangement for the board yet. - { widget = 'network', col = 14, row = 9, w = 3, h = 3 }, { widget = 'slackware', col = 12, row = 10, w = 2, h = 3 }, -- Centred on the board. Twice as many rows as columns because cells are -- about twice as wide as tall, so this span comes out square. diff --git a/lib/card.lua b/lib/card.lua index 7fd40f8..2749a6e 100644 --- a/lib/card.lua +++ b/lib/card.lua @@ -215,11 +215,7 @@ end -- fontconfig alone: a missing glyph draws as an invisible blank and a wrong -- codepoint as a plausible neighbour, so neither fails visibly. M.ICON = { - system = '\u{F0EE0}', -- cpu-64-bit - gpu = '\u{F083E}', -- video-4k-box (no GPU glyph exists) - disks = '\u{F02CA}', -- harddisk cache = '\u{F0ABA}', -- folder-clock - network = '\u{F0317}', -- lan slackware = '\u{F318}', -- linux-slackware breaktimer = '\u{F13AB}', -- timer calendar = '\u{F0E17}', -- calendar-month @@ -272,7 +268,7 @@ end -- Colour for a value against two thresholds. -- --- One function so all four system cards agree on the rule instead of each +-- One function so the cards agree on the rule instead of each -- re-deriving it, and so "what counts as busy" is stated in a single place. -- `v`, `warn` and `crit` share whatever unit the caller is using: percent for -- a filesystem, degrees for a sensor. @@ -283,78 +279,6 @@ function M.threshold(v, warn, crit, colors) return colors.ok end --- A horizontal bar: a dim full-width track with a filled portion over it. --- --- frac is clamped rather than trusted: a filesystem at 100% and a load that --- briefly computes above 1.0 must not draw past the track. -function M.bar(cr, x, y, w, h, frac, colour, colors) - frac = tonumber(frac) or 0 - if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end - - M.rgba(cr, colors.rule, 0.5) - M.rounded_path(cr, x, y, w, h, h / 2) - cairo_fill(cr) - - if frac > 0 then - M.rgba(cr, colour, 1) - M.rounded_path(cr, x, y, math.max(w * frac, h), h, h / 2) - cairo_fill(cr) - end -end - --- A vertical bar, rising from its baseline. The equaliser's element. -function M.vbar(cr, x, base_y, w, max_h, frac, colour, colors) - frac = tonumber(frac) or 0 - if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end - - M.rgba(cr, colors.rule, 0.5) - cairo_rectangle(cr, x, base_y - max_h, w, max_h) - cairo_fill(cr) - - local h = max_h * frac - if h > 0 then - M.rgba(cr, colour, 1) - cairo_rectangle(cr, x, base_y - h, w, h) - cairo_fill(cr) - end -end - --- A ring gauge, after idea2.png: a dim full circle with an arc over it --- covering `frac`, leaving the centre free for a glyph. --- --- The arc starts at twelve o'clock and sweeps clockwise, which is what reads --- as a gauge. Cairo's zero angle is at three o'clock and it sweeps clockwise --- already, so the start is offset by -pi/2 rather than the direction being --- reversed. -function M.ring(cr, cx, cy, r, frac, colour, colors, width) - frac = tonumber(frac) or 0 - if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end - width = width or math.max(3, r * 0.18) - - -- Start a fresh path. A preceding card.text() leaves a current point behind, - -- and cairo_arc() joins to it with a straight line, so without this every - -- ring after a label is drawn with a stray chord to the text baseline. - cairo_new_path(cr) - local prev_width = cairo_get_line_width(cr) - cairo_set_line_width(cr, width) - cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND) - - M.rgba(cr, colors.rule, 0.5) - cairo_arc(cr, cx, cy, r, 0, math.pi * 2) - cairo_stroke(cr) - - if frac > 0 then - M.rgba(cr, colour, 1) - cairo_arc(cr, cx, cy, r, -math.pi / 2, -math.pi / 2 + math.pi * 2 * frac) - cairo_stroke(cr) - end - -- Leave the line state as it was found. A later stroke inheriting ROUND gets - -- visibly rounded ends on the card's hairlines, and one inheriting this - -- width gets a hairline several pixels thick. - cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT) - cairo_set_line_width(cr, prev_width) -end - -- The largest base size S at which every row of pieces still fits max_w, -- measured at a reference size and scaled. -- @@ -383,19 +307,6 @@ function M.fit_unit(cr, max_w, groups, ref) return S end --- Bytes to a short human string: 1181116006 -> '1.1G'. --- The card formats its own numbers because the samplers pass raw bytes. -function M.human(bytes) - local n = tonumber(bytes) - if not n then return '--' end - local units = { 'B', 'K', 'M', 'G', 'T', 'P' } - local i = 1 - while n >= 1024 and i < #units do n = n / 1024; i = i + 1 end - if i == 1 then return string.format('%d%s', math.floor(n), units[i]) end - if n >= 100 then return string.format('%.0f%s', n, units[i]) end - return string.format('%.1f%s', n, units[i]) -end - -- Shorten a string to `limit` characters, appending an ellipsis when it cuts. -- -- By character, never by byte: Lua's string.sub counts bytes, so slicing a @@ -417,58 +328,4 @@ function M.truncate(s, limit) return s:sub(1, cut - 1) .. '\u{2026}' end --- A line chart: several series sharing one baseline and one vertical scale. --- --- `series` is a list of { values = , colour = }. `max` is the --- shared ceiling; passing one rather than computing per series is the whole --- point, because two series scaled independently lie about their relative --- size: a 200kB/s upload would draw the same height as a 40MB/s download. --- --- Values are drawn oldest-left, one per array entry, so a caller sizing its --- history to the pixel width gets one sample per column and no interpolation. --- A series shorter than its buffer draws only what it has, which is what a --- freshly started dashboard shows while the window fills. -function M.plot(cr, x, y, w, h, series, max, colors) - if not (w > 0 and h > 0) then return end - max = tonumber(max) or 0 - if max <= 0 then max = 1 end -- an idle link is a flat line, not a division by zero - - local prev_width = cairo_get_line_width(cr) - local prev_cap = cairo_get_line_cap(cr) - - -- The baseline, so an empty chart still reads as a chart rather than a gap. - M.rgba(cr, colors.rule, 0.5) - cairo_new_path(cr) - cairo_set_line_width(cr, 1) - cairo_move_to(cr, x, y + h) - cairo_line_to(cr, x + w, y + h) - cairo_stroke(cr) - - cairo_set_line_width(cr, math.max(1.5, h * 0.02)) - cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND) - - for _, s in ipairs(series) do - local v = s.values - local n = #v - if n >= 2 then - -- One sample per column when the caller sized its buffer to the width. - local step = w / math.max(n - 1, 1) - M.rgba(cr, s.colour, 1) - cairo_new_path(cr) - for i = 1, n do - local frac = v[i] / max - if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end - local px = x + (i - 1) * step - local py = y + h - frac * h - if i == 1 then cairo_move_to(cr, px, py) else cairo_line_to(cr, px, py) end - end - cairo_stroke(cr) - end - end - - -- Leave the line state as it was found, for the same reason card.ring does. - cairo_set_line_width(cr, prev_width) - cairo_set_line_cap(cr, prev_cap) -end - return M diff --git a/lib/data.lua b/lib/data.lua index d02d0f7..804398e 100644 --- a/lib/data.lua +++ b/lib/data.lua @@ -1,54 +1,13 @@ --- Readers for /proc and /sys. +-- Parsers for the sampler caches. -- -- Every function takes the file *contents* as a string rather than reading the -- file itself, so the parsers are testable against fixtures without mocking the --- filesystem. The thin read-and-parse wrappers live at the bottom. +-- filesystem. local M = {} --- Aggregate CPU jiffies from /proc/stat. Returns total, idle. --- iowait counts as idle: a core waiting on disk is not doing work, and --- treating it as busy makes a disk-bound system look CPU-bound. -function M.cpu_times(stat) - local line = stat:match('^cpu%s+([^\n]+)') - if not line then return nil, nil end - local v = {} - for n in line:gmatch('%d+') do v[#v + 1] = tonumber(n) end - if #v < 5 then return nil, nil end - local total = 0 - for _, n in ipairs(v) do total = total + n end - return total, v[4] + v[5] -- idle + iowait -end - --- /proc/meminfo, values in kB as the file states them. -function M.mem_info(meminfo) - local function field(name) - return tonumber(meminfo:match(name .. ':%s+(%d+)')) - end - local total = field('MemTotal') - local available = field('MemAvailable') - if not (total and available) then return nil end - return { - total = total, - free = field('MemFree'), - available = available, - -- MemAvailable already excludes reclaimable cache, so this is the figure a - -- user recognises as "used", unlike total-free which counts cache. - used = total - available, - } -end - --- hwmon temp*_input is millidegrees C. Returns whole degrees, or nil. --- nil rather than 0 on failure: 0 C is a legitimate reading. -function M.millidegrees(s) - if not s then return nil end - local n = tonumber(s:match('^%s*(-?%d+)')) - if not n then return nil end - return math.floor(n / 1000 + 0.5) -end - --- Read a whole file, returning nil if it cannot be read. Used by the wrappers --- so a vanished sysfs path degrades to nil instead of raising. +-- Read a whole file, returning nil if it cannot be read, so a missing cache +-- degrades to nil instead of raising. function M.slurp(path) local f = io.open(path, 'r') if not f then return nil end @@ -57,192 +16,6 @@ function M.slurp(path) return s end --- Find a hwmon directory by the exact contents of its `name` file. --- --- Globbing by name, never by a fixed hwmon index: indices drift across kernel --- and hardware reorders, and a stale index silently reports a different chip. --- Carried over from the previous conky config, where this was the hard-won bit. --- --- The result is memoized per chip name, hits and misses alike. A chip's hwmon --- directory is stable for the process's life, and the draw hook calls this --- several times per frame: without the cache every sensor read popens grep, --- which is exactly the subprocess-per-draw the samplers exist to avoid. A miss --- is cached too, so an absent chip is not re-globbed on every frame. -local hwmon_cache = {} - -function M.hwmon_dir(name) - if type(name) ~= 'string' then return nil end - if hwmon_cache[name] == nil then - local p = io.popen('grep -lx ' .. ("%q"):format(name) - .. ' /sys/class/hwmon/hwmon*/name 2>/dev/null') - local dir = false - if p then - local hit = p:read('*l') - p:close() - if hit then dir = hit:match('^(.*)/name$') end - end - hwmon_cache[name] = dir - end - return hwmon_cache[name] or nil -end - --- A stateful CPU-load counter. --- --- Load is work done between two samples, so a single reading cannot produce a --- percentage. Each counter keeps its own previous sample, which also means a --- per-core counter is just another instance. -function M.new_cpu_counter() - return { - prev_total = nil, - prev_idle = nil, - -- Returns busy percent since the previous sample, or nil when there is no - -- usable delta (first call, or the counters did not advance). - sample = function(self, total, idle) - if not (total and idle) then return nil end - local pt, pi = self.prev_total, self.prev_idle - self.prev_total, self.prev_idle = total, idle - if not pt then return nil end - local dt = total - pt - if dt <= 0 then return nil end - local busy = (dt - (idle - pi)) / dt * 100 - -- Clamp: a counter reset or a suspend/resume can produce a nonsense - -- delta, and a bar drawn at -12% or 340% is worse than a clamped one. - if busy < 0 then busy = 0 elseif busy > 100 then busy = 100 end - return busy - end, - } -end - --- A stateful byte-rate counter, for an interface's rx/tx totals. --- --- Same shape as new_cpu_counter and for the same reason: the counters are --- cumulative, so one reading cannot produce a rate, and each direction needs --- its own previous sample. --- --- `fallback_dt` is the draw interval, used when two samples land in the same --- clock second. os.time() resolves to whole seconds and the dashboard draws --- every two, so equal timestamps are ordinary, not exceptional. -function M.new_rate_counter(fallback_dt) - return { - prev_bytes = nil, - prev_time = nil, - -- Returns bytes per second since the previous sample, or nil when there is - -- no usable delta (first call, or a nil reading from a vanished interface). - sample = function(self, bytes, now) - if type(bytes) ~= 'number' then return nil end - now = now or os.time() - local pb, pt = self.prev_bytes, self.prev_time - self.prev_bytes, self.prev_time = bytes, now - if not pb then return nil end - - local dt = now - pt - -- Same second, or a clock that went backwards over an NTP step. - if dt <= 0 then dt = fallback_dt or 2 end - - local db = bytes - pb - -- A negative delta is a 32-bit wrap or an interface reset. Zero, never - -- the huge positive the wrap arithmetic would imply: one bogus sample - -- sets the shared autoscale and flattens the whole window. - if db < 0 then db = 0 end - - return db / dt - end, - } -end - --- Per-core jiffies from /proc/stat, in file order, so entry N is core N. --- --- Separate from cpu_times() rather than a flag on it: the aggregate is a --- single pair and this is a list, and a function returning one or the other --- depending on an argument is worse than two functions. --- --- The leading 'cpu ' aggregate is excluded by requiring a digit after 'cpu', --- since counting it would draw an extra bar showing the average alongside the --- real cores. -function M.per_cpu_times(stat) - local out = {} - if type(stat) ~= 'string' then return out end - for line in stat:gmatch('[^\n]+') do - local nums = line:match('^cpu%d+%s+(.+)$') - if nums then - local v = {} - for n in nums:gmatch('%d+') do v[#v + 1] = tonumber(n) end - -- Needs at least user..iowait to compute a busy fraction; a shorter line - -- is truncated or from a kernel that reports differently, and a partial - -- sum would look plausible while being wrong. - if #v >= 5 then - local total = 0 - for _, n in ipairs(v) do total = total + n end - out[#out + 1] = { total = total, idle = v[4] + v[5] } - end - end - end - return out -end - --- A named hwmon sensor's value in whole degrees, or nil. --- --- Wraps hwmon_dir + slurp + millidegrees so a widget names the chip and the --- file rather than building paths. nil, never 0: zero degrees is a plausible --- reading and must not be indistinguishable from a missing sensor. -function M.sensor(chip, file) - if type(chip) ~= 'string' or type(file) ~= 'string' then return nil end - local dir = M.hwmon_dir(chip) - if not dir then return nil end - return M.millidegrees(M.slurp(dir .. '/' .. file)) -end - --- A raw hwmon integer (fan RPM, power microwatts), or nil. Same lookup as --- sensor() without the millidegree conversion. -function M.sensor_raw(chip, file) - if type(chip) ~= 'string' or type(file) ~= 'string' then return nil end - local dir = M.hwmon_dir(chip) - if not dir then return nil end - local s = M.slurp(dir .. '/' .. file) - if not s then return nil end - return tonumber(s:match('^%s*(-?%d+)')) -end - --- Parse `df -P -B1` output into a list of filesystems. --- --- The mountpoint is taken as the LAST field, not the sixth. df -P guarantees --- one line per filesystem with the mountpoint last, and an NFS device reads --- 'server:/export', so counting fields from the left is fragile in a way --- counting from the right is not. --- --- Sizes are bytes because the sampler passes -B1. Never parse `df -h` output --- here: the user's shell aliases df to df -h, so a sampler calling bare df --- would hand this function '1.6G' where it expects an integer. -function M.df_parse(text) - local out = {} - if type(text) ~= 'string' then return out end - for line in text:gmatch('[^\n]+') do - -- A data row ends in 'NN% /some/path'. The header ends in 'Mounted on', - -- which fails the percent match, so it is skipped without a special case. - local size, used, avail, pct, mount = - line:match('(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%%%s+(%S+)%s*$') - if size then - -- The device is kept so a caller can tell an NFS mount from a local one - -- and recover its server, which is how the disks card labels the two - -- shares without hardcoding an address in the repo. - local dev = line:match('^(%S+)') - out[#out + 1] = { - mount = mount, - dev = dev, - host = dev and dev:match('^([^/:]+):') or nil, - size = tonumber(size), - used = tonumber(used), - -- Read, not derived: size - used overstates free space by the - -- root-reserved blocks, which is tens of gigabytes on a large - -- filesystem and is not available to anyone but root. - avail = tonumber(avail), - pct = tonumber(pct), - } - end - end - return out -end - -- Human-readable size to bytes: '1.1G' -> 1181116006. -- -- Needed because du -sh is what the sampler runs (its output is also what the @@ -302,107 +75,6 @@ function M.kv_parse(text) return out end --- The CPU's marketing name, stripped to what identifies it. --- --- '/proc/cpuinfo' repeats the model name once per thread; the first is enough. --- The stripping is rules, not a table of known CPUs: a leading vendor word, --- the registered-trademark noise, and a trailing core count or clock, all of --- which are constant boilerplate that costs a third of the row on a card --- measured in pixels. --- --- AMD Ryzen 7 9700X 8-Core Processor -> Ryzen 7 9700X --- Intel(R) Core(TM) i7-8700K CPU @ 3.7GHz -> Core i7-8700K -function M.cpu_model(cpuinfo) - if type(cpuinfo) ~= 'string' then return nil end - local s = cpuinfo:match('model name%s*:%s*([^\n]+)') - if not s then return nil end - s = s:gsub('%(R%)', ''):gsub('%(TM%)', ''):gsub('%(tm%)', '') - -- The vendor word can sit behind a generation prefix ('11th Gen Intel Core - -- i7-1165G7'), so the prefix goes first and the vendor anchor is applied - -- after it rather than only at the very start. - s = s:gsub('^%s*%d+th Gen%s+', '') - s = s:gsub('^%s*AMD%s+', ''):gsub('^%s*Intel%s+', '') - s = s:gsub('%s+%d+%-Core Processor.*$', '') - -- The clock tail, with or without a literal 'CPU' before the '@'. A Xeon - -- reads 'E5-2680 v4 @ 2.40GHz' and a Tiger Lake 'i7-1165G7 @ 2.80GHz', so - -- requiring the CPU token leaves the clock on the card for everything but - -- the one form the fixture happens to carry. - -- The clock tail goes first, then the bare 'CPU' token wherever it sits: a - -- Xeon reads 'Xeon CPU E5-2680 v4 @ 2.40GHz', so the token is mid-string, - -- not trailing. - s = s:gsub('%s*@.*$', ''):gsub('%s+CPU%f[%A]', '') - s = s:gsub('%s+Processor%s*$', '') - s = s:gsub('%s+', ' '):gsub('^%s+', ''):gsub('%s+$', '') - if s == '' then return nil end - return s -end - --- The motherboard, from the two world-readable DMI files. --- --- board_vendor and board_name are readable without privilege, unlike the --- serial fields in the same directory. The vendor's corporate suffix is --- dropped because every vendor has one and none of it identifies the board. --- --- A board reporting the DMI placeholder is treated as no board at all: --- 'To Be Filled By O.E.M.' on the dashboard is worse than a blank row. -local DMI_PLACEHOLDER = { - ['to be filled by o.e.m.'] = true, - ['system manufacturer'] = true, - ['default string'] = true, - ['unknown'] = true, - ['n/a'] = true, -} - -local function dmi_clean(s) - if type(s) ~= 'string' then return nil end - s = s:gsub('%s+', ' '):gsub('^%s+', ''):gsub('%s+$', '') - if s == '' or DMI_PLACEHOLDER[s:lower()] then return nil end - return s -end - -function M.board_name(vendor, name) - vendor = dmi_clean(vendor) - name = dmi_clean(name) - if vendor then - -- Corporate boilerplate, longest first so 'Co., Ltd.' does not leave 'Co.' - vendor = vendor:gsub('%s+Technology Co%.,? Ltd%.?$', '') - vendor = vendor:gsub('%s+COMPUTER INC%.?$', '') - vendor = vendor:gsub('%s+Corporation$', ''):gsub('%s+Corp%.?$', '') - vendor = vendor:gsub('%s+Inc%.?$', ''):gsub('%s+INC%.?$', '') - vendor = vendor:gsub('%s+Co%.,?%s*Ltd%.?$', ''):gsub('%s+CO%.,?%s*LTD%.?$', '') - vendor = vendor:gsub('%s+GmbH$', ''):gsub('%s+LLC$', '') - vendor = vendor:gsub('%s+$', '') - if vendor == '' then vendor = nil end - end - if vendor and name then return vendor .. ' ' .. name end - return name or vendor -end - --- The IPv4 address from `ip -4 addr show ` output. --- --- Split from the command that produces it so it can be tested against a --- fixture: every other parser in this file follows the same split, and an --- address is exactly the kind of value that must not be hardcoded in a test. -function M.iface_addr_parse(text) - if type(text) ~= 'string' then return nil end - return text:match('inet%s+(%d+%.%d+%.%d+%.%d+)') -end - --- The interface's IPv4 address, or nil when it has none. --- --- This shells out, which the draw hook otherwise never does, so the caller --- memoizes it: an address does not change without an event this dashboard --- does not watch. Retried while nil, because a bridge may not be up when --- conky starts. -function M.iface_addr(iface) - if type(iface) ~= 'string' then return nil end - local p = io.popen('ip -4 addr show ' .. ("%q"):format(iface) .. ' 2>/dev/null') - if not p then return nil end - local out = p:read('*a') - p:close() - return M.iface_addr_parse(out) -end - -- The calendar cache written by bin/calendar-sample.sh. -- -- Returns { today = { y, m, d }, colors = { = }, @@ -631,26 +303,6 @@ function M.immich_parse(text) return out end --- A sampling gate for counters that are read per draw. The dashboard draws --- every 0.1s while it is on screen, and a CPU or byte counter sampled that --- often gives coarse, flickering deltas, so those cards sample on this gate --- and redraw their last values in between. --- --- The returned function answers true at most once per `every` seconds, plus --- the clock it used: /proc/uptime, because os.time() counts whole seconds and --- a rate divided by a rounded interval is off by up to half. `now` is for the --- test. An unreadable clock opens the gate, which is the old per-draw behaviour. -function M.new_gate(every) - local last - return function(now) - now = now or tonumber((M.slurp('/proc/uptime') or ''):match('^(%S+)')) - if not now then return true, nil end - if last and now - last < every then return false, now end - last = now - return true, now - end -end - -- nvtable's plain table (`nvtable -C -E`), as bin/updates-sample.sh caches it: -- -- [personal] foo-bin 1.14.9 -> 1.14.9 !! ~ 1.14.10 @@ -690,45 +342,4 @@ function M.nvtable_parse(text) return seen and out or nil end --- VRAM in use on an xe device, in bytes, from DRM fdinfo: the same source --- gputop reads. Input is `grep -H` output, one 'path:key:value' line per --- field, so fields are grouped by the fd file they came from. --- --- One client (drm-client-id) is usually open on several fds, each repeating --- the client's full totals, so clients are counted once, not fds. Buffers --- shared between clients are still counted in each, as gputop does. -local UNIT = { B = 1, KiB = 1024, MiB = 1024 ^ 2, GiB = 1024 ^ 3 } - -function M.drm_vram_parse(text) - if type(text) ~= 'string' then return nil end - local fds = {} - for path, key, val in text:gmatch('([^\n]-):(drm%-[%w%-]+):%s*([^\n]*)') do - local fd = fds[path] or {} - fds[path] = fd - fd[key] = val - end - local seen, total = {}, nil - for _, fd in pairs(fds) do - local id = fd['drm-client-id'] - local n, unit = (fd['drm-resident-vram0'] or ''):match('^(%d+)%s*(%a*)') - if fd['drm-driver'] == 'xe' and id and n and not seen[id] then - seen[id] = true - total = (total or 0) + tonumber(n) * (UNIT[unit ~= '' and unit or 'B'] or 1) - end - end - return total -end - --- ponytail: a popen per call, ~10ms warm; callers gate it. Only fds of --- processes readable by conky's user are seen, so another user's GPU client --- (a system service) is not counted. -function M.drm_vram() - local p = io.popen("grep -sH -e '^drm-driver' -e '^drm-client-id'" - .. " -e '^drm-resident-vram0' /proc/[0-9]*/fdinfo/* 2>/dev/null") - if not p then return nil end - local out = p:read('*a') - p:close() - return M.drm_vram_parse(out) -end - return M diff --git a/test/fixtures/df_output b/test/fixtures/df_output deleted file mode 100644 index 9205c83..0000000 --- a/test/fixtures/df_output +++ /dev/null @@ -1,7 +0,0 @@ -Filesystem 1-blocks Used Available Capacity Mounted on -/dev/mapper/myvg-root 263174213632 208111570944 41746907136 84% / -/dev/mapper/myvg-home 719407267840 219043332096 463804289024 33% /home -/dev/sda1 983350091776 547869650944 385453473792 59% /data -server-a:/Volume1/Library 3958241259520 2966853025792 815851831296 79% /mnt/nfs/Library -server-b:/mnt/HD/HD_a2/share 982889670656 480039763968 503676862464 49% /mnt/nfs/shared -/dev/full 100000000000 100000000000 0 100% /mnt/full diff --git a/test/fixtures/ip_addr b/test/fixtures/ip_addr deleted file mode 100644 index 8d3f212..0000000 --- a/test/fixtures/ip_addr +++ /dev/null @@ -1,3 +0,0 @@ -4: br0: mtu 1500 qdisc noqueue state UP group default qlen 1000 - inet 192.0.2.15/24 brd 192.0.2.255 scope global br0 - valid_lft forever preferred_lft forever diff --git a/test/fixtures/proc_cpuinfo b/test/fixtures/proc_cpuinfo deleted file mode 100644 index e9cd523..0000000 --- a/test/fixtures/proc_cpuinfo +++ /dev/null @@ -1,12 +0,0 @@ -processor : 0 -vendor_id : AuthenticAMD -cpu family : 26 -model : 68 -model name : AMD Ryzen 7 9700X 8-Core Processor -stepping : 0 -cpu MHz : 4491.436 -cache size : 1024 KB - -processor : 1 -vendor_id : AuthenticAMD -model name : AMD Ryzen 7 9700X 8-Core Processor diff --git a/test/fixtures/proc_cpuinfo_intel b/test/fixtures/proc_cpuinfo_intel deleted file mode 100644 index 886b589..0000000 --- a/test/fixtures/proc_cpuinfo_intel +++ /dev/null @@ -1,6 +0,0 @@ -processor : 0 -vendor_id : GenuineIntel -cpu family : 6 -model : 158 -model name : Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz -stepping : 10 diff --git a/test/fixtures/proc_meminfo b/test/fixtures/proc_meminfo deleted file mode 100644 index 9c279aa..0000000 --- a/test/fixtures/proc_meminfo +++ /dev/null @@ -1,7 +0,0 @@ -MemTotal: 31943076 kB -MemFree: 4291244 kB -MemAvailable: 25627088 kB -Buffers: 123456 kB -Cached: 6789012 kB -SwapTotal: 8388604 kB -SwapFree: 8388604 kB diff --git a/test/fixtures/proc_stat b/test/fixtures/proc_stat deleted file mode 100644 index 2914cca..0000000 --- a/test/fixtures/proc_stat +++ /dev/null @@ -1,5 +0,0 @@ -cpu 209094 2199 135469 11604898 24408 0 946 0 0 0 -cpu0 5294 11 2928 740049 460 0 592 0 0 0 -cpu1 5210 18 2801 740512 431 0 12 0 0 0 -intr 12345678 -ctxt 987654321 diff --git a/test/fixtures/proc_stat_percore b/test/fixtures/proc_stat_percore deleted file mode 100644 index cbf62f2..0000000 --- a/test/fixtures/proc_stat_percore +++ /dev/null @@ -1,11 +0,0 @@ -cpu 293470 5201 186259 16513195 82679 0 2095 0 0 0 -cpu0 6441 24 3225 1056496 1932 0 760 0 0 0 -cpu1 23587 123 15762 1018523 8299 0 112 0 0 0 -cpu2 9859 24 6098 1049850 2386 0 81 0 0 0 -cpu3 11200 30 7000 1040000 2500 0 90 0 0 0 -intr 123456789 0 0 0 -ctxt 987654321 -btime 1789600000 -processes 54321 -procs_running 2 -procs_blocked 0 diff --git a/test/fixtures/temp1_input b/test/fixtures/temp1_input deleted file mode 100644 index 46fba94..0000000 --- a/test/fixtures/temp1_input +++ /dev/null @@ -1 +0,0 @@ -53125 diff --git a/test/test_data.lua b/test/test_data.lua index 549ce40..37a302e 100644 --- a/test/test_data.lua +++ b/test/test_data.lua @@ -20,141 +20,7 @@ local function read(path) return s end --- cpu_times: total and idle jiffies from the aggregate "cpu " line. -local total, idle = data.cpu_times(read('test/fixtures/proc_stat')) --- 209094+2199+135469+11604898+24408+0+946 = 11977014 -assert(total == 11977014, 'cpu total, got ' .. tostring(total)) --- idle field is the 4th value, 11604898; iowait (24408) counts as idle too -assert(idle == 11629306, 'cpu idle, got ' .. tostring(idle)) - --- mem_info: values in kB, as the file gives them. -local mem = data.mem_info(read('test/fixtures/proc_meminfo')) -assert(mem.total == 31943076, 'mem total, got ' .. tostring(mem.total)) -assert(mem.available == 25627088, 'mem available, got ' .. tostring(mem.available)) --- used is total minus available, which is what a user means by "used" -assert(mem.used == 6315988, 'mem used, got ' .. tostring(mem.used)) - --- millidegrees: hwmon temp*_input is millidegrees C, rounded to whole degrees. -assert(data.millidegrees(read('test/fixtures/temp1_input')) == 53, - 'temp, got ' .. tostring(data.millidegrees(read('test/fixtures/temp1_input')))) --- A missing or unreadable sensor must yield nil, not an error and not 0: --- 0 degrees is a plausible reading and would be indistinguishable from failure. -assert(data.millidegrees(nil) == nil, 'nil input must give nil') -assert(data.millidegrees('') == nil, 'empty input must give nil') -assert(data.millidegrees('garbage') == nil, 'unparseable input must give nil') - --- CPU load is a delta between two samples, so the counter holds state. --- The first call has no previous sample and must report nil, not a number: --- any number it invented would be wrong, and 100% on startup looks like a --- real spike. -local c = data.new_cpu_counter() -assert(c:sample(1000, 900) == nil, 'first sample must give nil') - --- Second sample: 100 more total jiffies, 50 of them idle, so 50% busy. --- Held in a local first: calling sample() again inside the assert message would --- advance the counter a third time. -local busy = c:sample(1100, 950) -assert(busy == 50.0, 'second sample, got ' .. tostring(busy)) - --- A counter that did not advance means no elapsed time, not 0% load. -local c2 = data.new_cpu_counter() -c2:sample(1000, 900) -assert(c2:sample(1000, 900) == nil, 'zero delta must give nil, not a division by zero') - --- === Per-core CPU ========================================================= --- The aggregate line answers "how busy is the machine"; the equaliser needs --- one entry per core. Both come from the same file, so they are parsed by the --- same rules and differ only in which lines they read. -local percore = read('test/fixtures/proc_stat_percore') -local cores = data.per_cpu_times(percore) -assert(#cores == 4, 'one entry per cpuN line, got ' .. tostring(#cores)) - --- cpu0: 6441+24+3225+1056496+1932+0+760 = 1068878 total, idle+iowait = 1058428 -assert(cores[1].total == 1068878, 'core 0 total, got ' .. tostring(cores[1].total)) -assert(cores[1].idle == 1058428, 'core 0 idle, got ' .. tostring(cores[1].idle)) - --- Ordering matters: bar N must be core N, so the list follows the file. -assert(cores[2].total == 1066406, 'core 1 total, got ' .. tostring(cores[2].total)) - --- The aggregate 'cpu ' line must NOT be counted as a core: it would draw a --- bar for the aggregate line showing the average, which looks like a real core. -for i, c in ipairs(cores) do - assert(c.total < 2000000, 'entry ' .. i .. ' looks like the aggregate line') -end - --- A counter per core is just another instance, which is why new_cpu_counter --- holds its own previous sample rather than using a module-level one. -local c0 = data.new_cpu_counter() -assert(c0:sample(cores[1].total, cores[1].idle) == nil, 'first sample is nil') -local busy = c0:sample(cores[1].total + 100, cores[1].idle + 25) -assert(busy == 75.0, 'second sample 75%, got ' .. tostring(busy)) - --- Malformed input yields an empty list, never an error: a raise here is a --- blank dashboard. -assert(#data.per_cpu_times('') == 0, 'empty input gives an empty list') -assert(#data.per_cpu_times('garbage\nlines\n') == 0, 'garbage gives an empty list') -assert(#data.per_cpu_times(nil) == 0, 'nil gives an empty list') --- A truncated line (fewer than the 5 fields the maths needs) is skipped --- rather than producing a nonsense total. -assert(#data.per_cpu_times('cpu0 1 2\n') == 0, 'a short line is skipped') - -- === Cache files ========================================================== --- df -P guarantees one line per mount with the mountpoint LAST, which is why --- the parser takes the last field rather than the sixth: an NFS device is --- 'server:/export' and a device name can carry surprises, but the mountpoint --- is always at the end. -local fs = data.df_parse(read('test/fixtures/df_output')) -assert(#fs == 6, 'one entry per mount, got ' .. tostring(#fs)) -assert(fs[1].mount == '/', 'first mount, got ' .. tostring(fs[1].mount)) -assert(fs[1].pct == 84, 'root percent, got ' .. tostring(fs[1].pct)) -assert(fs[1].size == 263174213632, 'root size in bytes, got ' .. tostring(fs[1].size)) -assert(fs[1].used == 208111570944, 'root used, got ' .. tostring(fs[1].used)) - --- Available is READ, never derived as size - used: the two differ by the --- root-reserved blocks, about 5%, which on this root is some 13GB that exists --- but cannot be used. A card claiming that space is free would be lying. -assert(fs[1].avail == 41746907136, 'root available, got ' .. tostring(fs[1].avail)) -assert(fs[1].size - fs[1].used ~= fs[1].avail, - 'the fixture must exercise the reserved-block gap, or this assertion proves nothing') - --- fs[3] is /dev/sda1: a device name ENDING IN A DIGIT, next to a numeric --- column. A parser counting fields from the left, or matching digits without --- anchoring, reads the partition number as a size. -assert(fs[3].mount == '/data', 'sda1 mount, got ' .. tostring(fs[3].mount)) -assert(fs[3].dev == '/dev/sda1', 'sda1 device, got ' .. tostring(fs[3].dev)) -assert(fs[3].size == 983350091776, 'sda1 size, got ' .. tostring(fs[3].size)) -assert(fs[3].used == 547869650944, 'sda1 used, got ' .. tostring(fs[3].used)) -assert(fs[3].avail == 385453473792, 'sda1 available, got ' .. tostring(fs[3].avail)) -assert(fs[3].pct == 59, 'sda1 percent, got ' .. tostring(fs[3].pct)) -assert(fs[3].host == nil, 'a local device has no host') - --- A full filesystem reports zero available, which is a real reading. -assert(fs[6].avail == 0, 'a full mount has 0 available, got ' .. tostring(fs[6].avail)) - --- The NFS rows are the reason the last field matters: a colon in the device --- would break a parser splitting on punctuation. -assert(fs[4].mount == '/mnt/nfs/Library', 'nfs mount, got ' .. tostring(fs[4].mount)) -assert(fs[4].pct == 79, 'nfs percent, got ' .. tostring(fs[4].pct)) - --- The device field carries the server, which is what lets the disks card --- label the two shares without an address hardcoded in the repo. -assert(fs[4].host == 'server-a', 'nfs host, got ' .. tostring(fs[4].host)) -assert(fs[1].host == nil, 'a local device has no host, got ' .. tostring(fs[1].host)) - --- A full filesystem is a real state, not an error. -assert(fs[6].pct == 100, 'a full mount reads 100, got ' .. tostring(fs[6].pct)) - --- The header line must not become a row. -for _, e in ipairs(fs) do - assert(e.mount ~= 'on' and e.mount ~= 'Mounted', 'the header leaked in as a row') -end - -assert(#data.df_parse('') == 0, 'empty input gives an empty list') -assert(#data.df_parse(nil) == 0, 'nil gives an empty list') --- A header with no rows is what a failed df produces. -assert(#data.df_parse('Filesystem 1-blocks Used Available Capacity Mounted on\n') == 0, - 'a header alone gives an empty list') - -- du -sh: total first, then children largest-first. The suffixes must be -- converted, not string-sorted: '1.1G' outranks '245M' numerically and loses -- to it alphabetically. @@ -199,46 +65,6 @@ assert(cut == string.rep('\u{00E9}', 9) .. '\u{2026}', assert(card.truncate(nil, 10) == '', 'nil truncates to empty') assert(card.truncate('abc', 0) == '', 'a zero limit gives empty') --- === Network rate ========================================================= --- Interface byte counters are cumulative, so a rate is a delta over elapsed --- time. The first call has no previous sample and must report nil: any number --- it invented would be wrong, and a spike at startup looks real. -local r = data.new_rate_counter() -assert(r:sample(1000, 100) == nil, 'first sample must give nil') - --- 2048 bytes over 2 seconds is 1024 B/s. -local rate = r:sample(3048, 102) -assert(rate == 1024, 'second sample, got ' .. tostring(rate)) - --- A counter that went backwards means a wrap or an interface reset. It must --- clamp to zero, never produce the huge positive an unsigned wrap implies: --- one bogus sample poisons the shared autoscale for the whole window. -local r2 = data.new_rate_counter() -r2:sample(5000, 100) -assert(r2:sample(10, 102) == 0, 'a counter reset must give 0') - --- Two samples inside the same clock second. os.time() has whole-second --- resolution against a 2s draw interval, so this happens in normal operation --- and must not divide by zero. -local r3 = data.new_rate_counter() -r3:sample(1000, 500) -local same_second = r3:sample(2000, 500) -assert(same_second ~= nil and same_second >= 0, - 'a zero time delta must fall back to the draw interval, got ' .. tostring(same_second)) - --- A missing interface reads nil, which must propagate rather than raise. -local r4 = data.new_rate_counter() -assert(r4:sample(nil, 100) == nil, 'a nil byte count gives nil') - --- The fallback interval is a constructor argument, and the widget passes --- conky's real update_interval rather than letting it default. That is the --- path that ships, so it is the path that gets a test: at a 5s interval the --- same 2048 bytes must read as 409.6 B/s, not 1024. -local r5 = data.new_rate_counter(5) -r5:sample(1000, 100) -assert(r5:sample(3048, 100) == 2048 / 5, - 'an explicit fallback interval must be used, got ' .. tostring(r5:sample(3048, 100))) - -- === kv_parse ============================================================= -- The slackware sampler writes 'key value' lines. The format is deliberately -- dumber than the data: epochs stay integers rather than becoming formatted @@ -260,68 +86,6 @@ assert(type(data.kv_parse('')) == 'table', 'empty input gives a table') assert(type(data.kv_parse(nil)) == 'table', 'nil gives a table') assert(data.kv_parse('keyonly\n').keyonly == nil, 'a line with no value is skipped') --- === Hardware identification ============================================== --- Both strings are constant for the machine's life, so the widget memoizes --- them. What is tested here is the stripping, which must be a rule rather --- than a special case for one host: the Intel fixture exists to prove it. -assert(data.cpu_model(read('test/fixtures/proc_cpuinfo')) == 'Ryzen 7 9700X', - 'amd cpu, got ' .. tostring(data.cpu_model(read('test/fixtures/proc_cpuinfo')))) -assert(data.cpu_model(read('test/fixtures/proc_cpuinfo_intel')) == 'Core i7-8700K', - 'intel cpu, got ' .. tostring(data.cpu_model(read('test/fixtures/proc_cpuinfo_intel')))) - --- Unparseable input yields nil, so the card shows nothing rather than a --- half-stripped string. -assert(data.cpu_model('') == nil, 'empty cpuinfo gives nil') -assert(data.cpu_model(nil) == nil, 'nil cpuinfo gives nil') -assert(data.cpu_model('processor\t: 0\n') == nil, 'cpuinfo with no model name gives nil') - --- The board is two sysfs files joined, with the vendor's corporate suffix --- dropped: it is boilerplate on every board and costs a third of the row. -assert(data.board_name('Gigabyte Technology Co., Ltd.\n', 'X870 EAGLE WIFI7\n') - == 'Gigabyte X870 EAGLE WIFI7', - 'board, got ' .. tostring(data.board_name('Gigabyte Technology Co., Ltd.\n', 'X870 EAGLE WIFI7\n'))) -assert(data.board_name('ASUSTeK COMPUTER INC.\n', 'PRIME B650-PLUS\n') - == 'ASUSTeK PRIME B650-PLUS', - 'asus board, got ' .. tostring(data.board_name('ASUSTeK COMPUTER INC.\n', 'PRIME B650-PLUS\n'))) - --- A machine that reports one and not the other shows what it has. -assert(data.board_name(nil, 'X870 EAGLE WIFI7\n') == 'X870 EAGLE WIFI7', - 'name alone, got ' .. tostring(data.board_name(nil, 'X870 EAGLE WIFI7\n'))) -assert(data.board_name('Gigabyte\n', nil) == 'Gigabyte', 'vendor alone') -assert(data.board_name(nil, nil) == nil, 'neither gives nil') - --- A virtual machine reports placeholder DMI strings. Showing 'To be filled by --- O.E.M.' as the motherboard is worse than showing nothing. -assert(data.board_name('To Be Filled By O.E.M.\n', 'To Be Filled By O.E.M.\n') == nil, - 'placeholder DMI gives nil') - --- Three Intel forms the single fixture does not cover. The first fixture --- happens to carry a literal 'CPU' before the clock, so a pattern requiring --- that token passes the fixture and still leaves '@ 2.40GHz' on the card for --- every other Intel part. These exist so the stripping stays a rule. -assert(data.cpu_model('model name\t: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz\n') - == 'Xeon E5-2680 v4', - 'xeon, got ' .. tostring(data.cpu_model('model name\t: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz\n'))) -assert(data.cpu_model('model name\t: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz\n') - == 'Core i7-1165G7', - 'tiger lake, got ' .. tostring(data.cpu_model('model name\t: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz\n'))) -assert(data.cpu_model('model name\t: AMD Ryzen 9 7950X3D 16-Core Processor\n') - == 'Ryzen 9 7950X3D', - 'x3d, got ' .. tostring(data.cpu_model('model name\t: AMD Ryzen 9 7950X3D 16-Core Processor\n'))) - --- === Interface address ==================================================== --- The fixture uses TEST-NET-1 (192.0.2.0/24, RFC 5737). This repository is --- public: no real LAN address belongs in it, and no test may assert one. -local addr = data.iface_addr_parse(read('test/fixtures/ip_addr')) -assert(addr == '192.0.2.15', 'lan address, got ' .. tostring(addr)) - --- A down interface has no inet line. nil, so the card shows '--' rather than --- a stale or invented address. -assert(data.iface_addr_parse('5: br0: mtu 1500 state DOWN\n') == nil, - 'a down interface gives nil') -assert(data.iface_addr_parse('') == nil, 'empty gives nil') -assert(data.iface_addr_parse(nil) == nil, 'nil gives nil') - -- === Calendar ============================================================= -- The fixture uses generic event titles. This repository is public: a real -- appointment names a real person or a real appointment, and neither belongs @@ -479,33 +243,6 @@ assert(up.upgrade == 3 and up.bump == 1 and up.current == 46 and up.missing == 1 'updates summary') assert(data.nvtable_parse('') == nil, 'updates: empty cache is nil') --- drm_vram_parse: xe clients counted once across their fds, other drivers --- skipped, units honoured. -local fdinfo = table.concat({ - '/proc/1/fdinfo/5:drm-driver:\txe', - '/proc/1/fdinfo/5:drm-client-id:\t7', - '/proc/1/fdinfo/5:drm-resident-vram0:\t1024 KiB', - '/proc/1/fdinfo/6:drm-driver:\txe', - '/proc/1/fdinfo/6:drm-client-id:\t7', - '/proc/1/fdinfo/6:drm-resident-vram0:\t1024 KiB', - '/proc/2/fdinfo/9:drm-driver:\txe', - '/proc/2/fdinfo/9:drm-client-id:\t8', - '/proc/2/fdinfo/9:drm-resident-vram0:\t2 MiB', - '/proc/3/fdinfo/4:drm-driver:\tamdgpu', - '/proc/3/fdinfo/4:drm-client-id:\t9', - '/proc/3/fdinfo/4:drm-resident-vram0:\t5 GiB', -}, '\n') -assert(data.drm_vram_parse(fdinfo) == 3 * 1024 ^ 2, - 'drm vram: got ' .. tostring(data.drm_vram_parse(fdinfo))) -assert(data.drm_vram_parse('') == nil, 'drm vram: no clients is nil') - --- new_gate: opens at most once per interval on the clock it is given. -local gate = data.new_gate(1.9) -assert(gate(100.0) == true, 'gate: first call opens') -assert(gate(100.5) == false, 'gate: closed inside the interval') -assert(gate(101.95) == true, 'gate: opens after it') -assert(gate(102.0) == false, 'gate: interval restarts from the last open') - -- Emoji fall back to card.FONT_EMOJI: the toy API has no per-glyph fallback, -- so without the run split "x🦷" measured a tofu box in Noto Sans. do diff --git a/widgets/disks.lua b/widgets/disks.lua deleted file mode 100644 index 3922188..0000000 --- a/widgets/disks.lua +++ /dev/null @@ -1,150 +0,0 @@ --- Disks: one ring per filesystem, after idea2.png. --- --- Reads a cache file written by bin/disks-sample.sh and never calls statfs --- itself: an unreachable NFS server blocks that call, and blocking the draw --- freezes the whole dashboard. - -local card = require 'lib.card' -local data = require 'lib.data' - -local M = {} - -local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache')) - .. '/udt/disks.txt' - --- mount -> centre glyph and the small text under it. --- --- The icon IS the label: no mount name is drawn, because a Slackware S or a --- house says which filesystem this is faster than the word does. Every --- codepoint here was taken from the old conky config and re-rendered to --- confirm it, rather than guessed: an earlier guess at the Slackware mark --- (F83C) turned out to draw a stomach, and a wrong-but-present codepoint --- fails plausibly rather than visibly. --- --- The two NFS shares are told apart by their server's address, which is read --- from the mount's device field at runtime rather than written here. The --- addresses belong on the screen, not in a public repo, and deriving them also --- means the card keeps working if the LAN is renumbered. It fits: at a 50px --- radius the usable inner width is about 75px and an address measures 58px at --- 10px type. --- --- A mount not listed still draws, with a generic disk glyph and the last path --- segment, so adding one to the sampler needs no change here. -local GLYPH = { - ['/'] = '\u{F318}', -- Slackware - ['/home'] = '\u{F015}', -- house - ['/data'] = '\u{F02CA}', -- hard disk -} -local NFS_GLYPH = '\u{F06F3}' -- network share -local FALLBACK_GLYPH = '\u{F02CA}' - -function M.draw(cr, rect, colors) - local inner = card.card(cr, rect, colors) - local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end - local label_size = clamp(inner.w * 0.030, 10, 16) - - local fs = data.df_parse(data.slurp(CACHE) or '') - if #fs == 0 then - card.notice(cr, inner, colors, 'DISKS', 'no disk data', 'bin/disks-sample.sh') - return - end - - card.font(cr, card.FONT_MONO, label_size, false) - card.title(cr, inner.x, inner.y + label_size, card.ICON.disks, 'DISKS', colors) - - local top = inner.y + label_size * 2.2 - local avail_h = (inner.y + inner.h) - top - - -- All rings share one row: the cell width shrinks as mounts are added and - -- the ring radius follows it, so the layout never wraps. Below the radius - -- where a ring reads, fall back to labelled bars rather than drawing a row - -- of illegible dots. - local n = #fs - local per_row = n - local cell_w = inner.w / per_row - -- 0.27 rather than 0.30: two rows sit under each ring now, not one, and the - -- radius is what gives up the height. - local r = math.min(cell_w * 0.34, avail_h * 0.27) - - if r >= 18 then - for i, e in ipairs(fs) do - -- host is non-nil exactly for a network mount, since a local device is - -- a path and carries no 'server:' prefix. - local glyph = GLYPH[e.mount] or (e.host and NFS_GLYPH) or FALLBACK_GLYPH - local sub = e.host - if not sub and not GLYPH[e.mount] then - sub = (e.mount:match('([^/]+)/?$') or e.mount):upper() - end - local cx = inner.x + cell_w * (i - 0.5) - local cy = top + r + 6 - local colour = card.threshold(e.pct, 25, 75, colors) - - card.ring(cr, cx, cy, r, e.pct / 100, colour, colors) - - -- Glyph at the centre. Drawn at 0.55 of the radius rather than 0.7: the - -- network-share glyph carries a wide horizontal connector that runs past - -- the ring at larger sizes, which reads as a line joining the rings - -- together. - local gsize = r * 0.55 - card.font(cr, card.FONT_MONO, gsize, false) - card.rgba(cr, colors.label) - local gw = card.measure(cr, glyph) - -- Raised when there is text under it, centred when there is not. - card.text(cr, cx - gw / 2, cy + (sub and gsize * 0.10 or gsize * 0.38), glyph) - - -- The address, inside the ring beneath the glyph. Only the NFS shares - -- have one; the local mounts are identified by their icon alone. - if sub then - -- Tied to the card's own label scale rather than a fixed px floor, so - -- every piece of small mono text on the card tracks the same size. - card.font(cr, card.FONT_MONO, clamp(r * 0.20, label_size * 0.7, label_size), false) - card.rgba(cr, colors.label) - local sw_ = card.measure(cr, sub) - card.text(cr, cx - sw_ / 2, cy + r * 0.55, sub) - end - - -- Free on top, total beneath, both centred under the ring. - -- - -- The percentage that used to sit here is gone: the ring already draws - -- it as an angle, and a number repeating it costs a row in a column - -- about 75px wide. Free carries the threshold colour because it is the - -- measured quantity; the total is fixed, so it stays in `value`. One - -- coloured number per column, as the rest of the board reads. - local free_size = label_size - local total_size = label_size * 0.88 - card.font(cr, card.FONT_MONO, free_size, false) - card.rgba(cr, colour) - local free_txt = card.human(e.avail) .. ' free' - local fw = card.measure(cr, free_txt) - card.text(cr, cx - fw / 2, cy + r + free_size * 1.7, free_txt) - - card.font(cr, card.FONT_MONO, total_size, false) - card.rgba(cr, colors.value) - local total_txt = card.human(e.size) - local tw = card.measure(cr, total_txt) - card.text(cr, cx - tw / 2, cy + r + free_size * 1.7 + total_size * 1.4, total_txt) - end - else - local step = label_size * 2.6 - local bar_h = clamp(label_size * 0.5, 4, 8) - local ey = top + label_size - for _, e in ipairs(fs) do - if ey + step > inner.y + inner.h then break end - -- The bar fallback has no room for a ring, so it shows the glyph inline - -- followed by the address or the mount name. - local glyph = GLYPH[e.mount] or (e.host and NFS_GLYPH) or FALLBACK_GLYPH - local name = e.host or (e.mount:match('([^/]+)/?$') or e.mount):upper() - local colour = card.threshold(e.pct, 25, 75, colors) - card.font(cr, card.FONT_MONO, label_size, false) - card.rgba(cr, colors.label) - card.text(cr, inner.x, ey, glyph .. ' ' .. name) - card.rgba(cr, colour) - card.text_right(cr, inner.x + inner.w, ey, - string.format('%s / %s', card.human(e.used), card.human(e.size))) - card.bar(cr, inner.x, ey + label_size * 0.5, inner.w, bar_h, e.pct / 100, colour, colors) - ey = ey + step - end - end -end - -return M diff --git a/widgets/gpu.lua b/widgets/gpu.lua deleted file mode 100644 index abac856..0000000 --- a/widgets/gpu.lua +++ /dev/null @@ -1,121 +0,0 @@ --- GPU: Arc B580 temperatures, VRAM in use and fan speed. --- --- No utilisation figure, deliberately. The xe driver exposes no --- gpu_busy_percent and intel_gpu_top refuses the device outright ("Detected Xe --- device which is not supported"). VRAM comes from DRM fdinfo, the per-client --- counters gputop itself reads (see data.drm_vram). --- --- The integrated AMD GPU does expose gpu_busy_percent and is deliberately not --- shown: the discrete card is the one in use. - -local card = require 'lib.card' -local data = require 'lib.data' - -local M = {} - --- The B580's VRAM. fdinfo reports what clients hold, not the card's size, and --- xe puts no capacity in sysfs. ponytail: hardcoded like the 'ARC B580' label. -local VRAM_TOTAL = 12 * 1024 ^ 3 - --- fdinfo is a popen over every fd on the system, so sample it every ~2s, not --- every frame. -local gate = data.new_gate(1.9) -local vram - --- chip, file, label, warn, crit. Package (temp2) is the headline above, so it --- is not repeated here; these are the rows drawn under it. -local TEMPS = { - { 'xe', 'temp3_input', 'VRAM', 80, 90 }, -} - -function M.draw(cr, rect, colors) - local inner = card.card(cr, rect, colors) - local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end - - if gate() then vram = data.drm_vram() end - - local pkg = data.sensor('xe', 'temp2_input') - local big_txt = pkg and string.format('%d\u{00B0}', pkg) or '--' - - -- Fluid type. One base size S drives every element. The cell's height sets - -- how large the content grows; the measured width fit pulls S back down - -- only where the header or a row would overrun the card. So a tall cell - -- gets larger type instead of empty space, and nothing ever collides. - local LABEL_F, ROW_F = 0.32, 0.34 - local row_n = #TEMPS + 2 -- VRAM + MEM + FAN - local S_h = inner.h * 0.94 / (2.088 + row_n * 0.646 + 0.3) -- + MEM bar - local groups = { - { { card.title_str(card.ICON.gpu, 'GPU'), card.FONT_MONO, LABEL_F }, - { big_txt, card.FONT_HEAVY, 1.0 } }, - { { 'ARC B580', card.FONT_MONO, LABEL_F } }, - } - for _, t in ipairs(TEMPS) do - groups[#groups + 1] = { { t[3], card.FONT_MONO, ROW_F }, - { '888\u{00B0}', card.FONT_MONO, ROW_F } } - end - groups[#groups + 1] = { { 'MEM', card.FONT_MONO, ROW_F }, - { '100%', card.FONT_MONO, ROW_F } } - groups[#groups + 1] = { { 'FAN', card.FONT_MONO, ROW_F }, - { '8888 RPM', card.FONT_MONO, ROW_F } } - local S = clamp(math.min(S_h, card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72) - local label_size = S * LABEL_F - local row_size = S * ROW_F - - local y = inner.y - - -- The package temperature is the headline, since there is no load to show. - -- It shares the header line with the label, set on the same baseline and - -- right-aligned, so the card's width carries the number rather than leaving - -- the right half empty under a left-set value. - card.font(cr, card.FONT_HEAVY, S, false) - card.rgba(cr, pkg and card.threshold(pkg, 75, 85, colors) or colors.label) - card.text_right(cr, inner.x + inner.w, y + S, big_txt) - - card.font(cr, card.FONT_MONO, label_size, false) - card.title(cr, inner.x, y + label_size, card.ICON.gpu, 'GPU', colors) - - card.rgba(cr, colors.value) - card.text(cr, inner.x, y + S + label_size * 1.7, 'ARC B580') - - local ey = y + S + label_size * 3.4 - local step = row_size * 1.9 - - card.font(cr, card.FONT_MONO, row_size, false) - for _, t in ipairs(TEMPS) do - if ey + step > inner.y + inner.h then break end - local v = data.sensor(t[1], t[2]) - card.rgba(cr, colors.label) - card.text(cr, inner.x, ey, t[3]) - card.rgba(cr, v and card.threshold(v, t[4], t[5], colors) or colors.label) - card.text_right(cr, inner.x + inner.w, ey, - v and string.format('%d\u{00B0}', v) or '--') - ey = ey + step - end - - -- VRAM as a fraction of the card's, a bar under the row like RAM in system. - local bar_h = S * 0.12 - if ey + step + bar_h + S * 0.1 <= inner.y + inner.h then - local frac = vram and vram / VRAM_TOTAL - local colour = frac and card.threshold(frac * 100, 25, 75, colors) or colors.label - card.rgba(cr, colors.label) - card.text(cr, inner.x, ey, 'MEM') - card.rgba(cr, colour) - card.text_right(cr, inner.x + inner.w, ey, - frac and string.format('%d%%', math.floor(frac * 100 + 0.5)) or '--') - card.bar(cr, inner.x, ey + row_size * 0.6, inner.w, bar_h, frac, colour, colors) - ey = ey + step + bar_h + S * 0.1 - end - - -- Fan RPM is not a temperature, so it carries no threshold colour: a fast - -- fan is the cooling working, not a fault. - if ey + step <= inner.y + inner.h then - local rpm = data.sensor_raw('xe', 'fan1_input') - card.rgba(cr, colors.label) - card.text(cr, inner.x, ey, 'FAN') - card.rgba(cr, colors.value) - card.text_right(cr, inner.x + inner.w, ey, - rpm and string.format('%d RPM', rpm) or '--') - end -end - -return M diff --git a/widgets/network.lua b/widgets/network.lua deleted file mode 100644 index 20b0573..0000000 --- a/widgets/network.lua +++ /dev/null @@ -1,181 +0,0 @@ --- Network: throughput as two lines on one chart, with the LAN and public --- addresses. --- --- The rates come from the interface's own byte counters, read every draw: two --- file reads, no subprocess. The public address comes from a cache file, --- because a network call in the draw hook would freeze the dashboard when it --- hung. - -local card = require 'lib.card' -local data = require 'lib.data' - -local M = {} - --- The interface to graph. br0 is this host's bridge, matching the old text --- config. --- --- Note what this means: a bridge carries VM-to-host traffic that never reaches --- the router, so a local copy spikes the graph. That was true of the old --- config too and is accepted; this constant is the one edit that changes it. -local IFACE = 'br0' - -local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache')) - .. '/udt/pubip.txt' - --- A public address older than this is not shown. Eight sampling intervals: by --- then the sampler has failed repeatedly, and an address that may no longer be --- yours displayed with confidence is worse than '--'. -local STALE_AFTER = 4 * 3600 - -local STAT = '/sys/class/net/' .. IFACE .. '/statistics/' - --- Module state: the history outlives the frame, which is the whole point. --- It does not outlive a conky restart, so the chart starts empty and fills --- left to right. -local rx_counter, tx_counter -local rx_hist, tx_hist = {}, {} -local hist_len = 0 -local lan_addr = nil - --- Sampled every ~2s whatever the draw rate, so the chart keeps one point per --- sample rather than per draw. 1.9 not 2: draws jitter by a few ms. -local gate = data.new_gate(1.9) -local down, up = nil, nil - --- Append to a fixed-length history, dropping the oldest. A plain array with --- table.remove(1) rather than a circular buffer with an index: the lengths --- here are a few hundred at most and the draw is every two seconds, so the --- O(n) shift is free and the array is already in draw order. -local function push(hist, v, len) - hist[#hist + 1] = v - while #hist > len do table.remove(hist, 1) end -end - -local function read_counter(file) - local s = data.slurp(STAT .. file) - if not s then return nil end - return tonumber(s:match('^%s*(%d+)')) -end - --- Bytes per second to a short string. Deliberately not card.human: a rate --- wants a '/s' and one decimal at most, and reusing card.human would put a --- suffix meant for capacity onto a speed. -local function rate_str(bps) - if not bps then return '--' end - local units = { 'B', 'K', 'M', 'G' } - local n, i = bps, 1 - while n >= 1024 and i < #units do n = n / 1024; i = i + 1 end - if i == 1 then return string.format('%d%s/s', math.floor(n), units[i]) end - if n >= 100 then return string.format('%.0f%s/s', n, units[i]) end - return string.format('%.1f%s/s', n, units[i]) -end - -function M.draw(cr, rect, colors) - local inner = card.card(cr, rect, colors) - local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end - - local interval = (conky_info and conky_info.update_interval) or 2 - rx_counter = rx_counter or data.new_rate_counter(interval) - tx_counter = tx_counter or data.new_rate_counter(interval) - - local sampled, now = gate() - if sampled then - down = rx_counter:sample(read_counter('rx_bytes'), now) - up = tx_counter:sample(read_counter('tx_bytes'), now) - end - - -- Fluid type, as every other card does: one base size S drives everything, - -- taken as the smaller of a height budget and a measured width fit. - local LABEL_F, BIG_F, ROW_F = 0.30, 1.0, 0.30 - local groups = { - { { card.title_str(card.ICON.network, 'NET'), card.FONT_MONO, LABEL_F }, - { '888.8M/s', card.FONT_HEAVY, BIG_F } }, - { { '\u{F0318} 192.000.000.000', card.FONT_MONO, ROW_F } }, - { { '\u{F01DA} 888.8M/s', card.FONT_MONO, ROW_F }, - { '\u{F0552} 888.8M/s', card.FONT_MONO, ROW_F } }, - } - local fixed_units = 1.0 + ROW_F * 2.2 * 3 - local S = clamp(math.min(inner.h * 0.96 / (fixed_units + 1.2), - card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72) - local label_size = S * LABEL_F - local row_size = S * ROW_F - - -- Header: the label left, the download rate as the big value at the right. - -- Download is the headline because it is the number that moves. - card.font(cr, card.FONT_HEAVY, S * BIG_F, false) - card.rgba(cr, colors.body) - card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, rate_str(down)) - - card.font(cr, card.FONT_MONO, label_size, false) - card.title(cr, inner.x, inner.y + label_size, card.ICON.network, 'NET', colors) - - local ey = inner.y + S * BIG_F + row_size * 1.4 - local step = row_size * 2.2 - - -- The addresses. LAN is memoized on first success and retried while nil: a - -- bridge may not be up when conky starts. - lan_addr = lan_addr or data.iface_addr(IFACE) - card.font(cr, card.FONT_MONO, row_size, false) - card.rgba(cr, colors.label) - card.text(cr, inner.x, ey, '\u{F0318}') -- LAN - card.rgba(cr, colors.value) - card.text_right(cr, inner.x + inner.w, ey, lan_addr or '--') - ey = ey + step - - local pub = data.kv_parse(data.slurp(CACHE) or '') - local fetched = tonumber(pub.fetched) - local pub_txt = '--' - if pub.ip and fetched and (os.time() - fetched) < STALE_AFTER then - pub_txt = pub.ip - end - card.rgba(cr, colors.label) - card.text(cr, inner.x, ey, '\u{F059F}') -- globe - card.rgba(cr, pub_txt == '--' and colors.label or colors.value) - card.text_right(cr, inner.x + inner.w, ey, pub_txt) - ey = ey + step * 1.1 - - -- The chart takes the room left between the addresses and the rate row. - local rate_row_h = row_size * 2.4 - local chart_top = ey - local chart_h = (inner.y + inner.h) - chart_top - rate_row_h - if chart_h > 12 then - -- One sample per pixel column, so the chart never interpolates. The - -- buffer is resized when the card is, keeping what it can: a moved card - -- should not clear the history. - local want = math.max(8, math.floor(inner.w)) - if want ~= hist_len then - hist_len = want - while #rx_hist > hist_len do table.remove(rx_hist, 1) end - while #tx_hist > hist_len do table.remove(tx_hist, 1) end - end - if sampled and down then push(rx_hist, down, hist_len) end - if sampled and up then push(tx_hist, up, hist_len) end - - -- One ceiling for both series. Scaled independently they would lie about - -- their relative size, which is the entire reason to draw them together. - local peak = 0 - for _, v in ipairs(rx_hist) do if v > peak then peak = v end end - for _, v in ipairs(tx_hist) do if v > peak then peak = v end end - - card.plot(cr, inner.x, chart_top, inner.w, chart_h, { - { values = rx_hist, colour = colors.ok }, - { values = tx_hist, colour = colors.highlight }, - }, peak, colors) - - -- The peak, so a full-height line means something in absolute terms. - card.font(cr, card.FONT_MONO, row_size * 0.85, false) - card.rgba(cr, colors.label) - card.text(cr, inner.x, chart_top + row_size * 0.85, rate_str(peak)) - end - - -- The live rates, each in its series colour so the line and the number are - -- unmistakably the same thing. - local ry = inner.y + inner.h - row_size * 0.4 - card.font(cr, card.FONT_MONO, row_size, false) - card.rgba(cr, colors.ok) - card.text(cr, inner.x, ry, '\u{F01DA} ' .. rate_str(down)) - card.rgba(cr, colors.highlight) - card.text_right(cr, inner.x + inner.w, ry, '\u{F0552} ' .. rate_str(up)) -end - -return M diff --git a/widgets/system.lua b/widgets/system.lua deleted file mode 100644 index c1cdf67..0000000 --- a/widgets/system.lua +++ /dev/null @@ -1,198 +0,0 @@ --- System: CPU load as a per-core equaliser, memory, and every temperature. --- --- The hwmon bindings and temperature ceilings below are host-specific, which --- is why they live here rather than in lib/data.lua: that file is the part --- that ports to another machine, this is the part that does not. - -local card = require 'lib.card' -local data = require 'lib.data' - -local M = {} - --- chip, file, label, warn, crit. --- --- Ceilings are per sensor because a single pair cannot serve both: 70C is --- unremarkable on a CPU package and alarming on an NVMe. Starting values, --- easy to retune once real numbers under load are known. -local TEMPS = { - { 'k10temp', 'temp1_input', 'CPU', 75, 90 }, - { 'k10temp', 'temp3_input', 'CCD1', 75, 90 }, - { 'nvme', 'temp1_input', 'NVME', 60, 70 }, - { 'gigabyte_wmi', 'temp2_input', 'BOARD', 60, 70 }, - { 'gigabyte_wmi', 'temp3_input', 'BOARD', 60, 70 }, -} - --- One counter per core, created on first draw and kept for the process's life: --- load is a delta between samples, so the state has to outlive the frame. -local counters = {} -local total_counter = data.new_cpu_counter() - --- Sampled every ~2s whatever the draw rate; the values between samples are --- the last ones read. 1.9 not 2: draws jitter by a few ms either way. -local gate = data.new_gate(1.9) -local load, pcts = nil, {} - --- Read once, not per frame. Neither can change without a reboot, and --- re-reading /proc/cpuinfo every two seconds for a constant is waste: the file --- repeats its model-name line once per thread. --- --- `false` is the cached miss, distinct from nil meaning "not looked up yet", --- so an absent DMI table is not re-read on every draw. -local cpu_name, board = nil, nil - -local function hardware() - if cpu_name == nil then - cpu_name = data.cpu_model(data.slurp('/proc/cpuinfo') or '') or false - end - if board == nil then - local dmi = '/sys/devices/virtual/dmi/id/' - board = data.board_name(data.slurp(dmi .. 'board_vendor'), - data.slurp(dmi .. 'board_name')) or false - end - return cpu_name or nil, board or nil -end - -function M.draw(cr, rect, colors) - local inner = card.card(cr, rect, colors) - local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end - - if gate() then - local stat = data.slurp('/proc/stat') or '' - load = total_counter:sample(data.cpu_times(stat)) - for i, c in ipairs(data.per_cpu_times(stat)) do - counters[i] = counters[i] or data.new_cpu_counter() - pcts[i] = counters[i]:sample(c.total, c.idle) or 0 - end - end - local load_txt = load and string.format('%d%%', math.floor(load + 0.5)) or '--' - local temp_n = #TEMPS - local cpu_txt, board_txt = hardware() - - -- Fluid type. One base size S drives the label, headline, RAM and every - -- temperature row; the equaliser then absorbs the vertical room left over, - -- so the card fills its cell and S shrinks only where a row would overrun - -- the width. card.fit_unit supplies the width side of that. - local LABEL_F, BIG_F, ROW_F = 0.30, 1.0, 0.32 - local groups = { - { { card.title_str(card.ICON.system, 'CPU'), card.FONT_MONO, LABEL_F }, - { load_txt, card.FONT_HEAVY, BIG_F } }, - { { cpu_txt or '', card.FONT_MONO, ROW_F } }, - { { board_txt or '', card.FONT_MONO, ROW_F } }, - { { 'RAM', card.FONT_MONO, ROW_F }, { '999.9G / 999.9G', card.FONT_MONO, ROW_F } }, - } - for _, t in ipairs(TEMPS) do - groups[#groups + 1] = { { t[3], card.FONT_MONO, ROW_F }, - { '888\u{00B0}', card.FONT_MONO, ROW_F } } - end - -- Height the non-equaliser content needs, in units of S, plus a reserve for - -- the equaliser band. The smaller of that budget and the width fit is S. - -- The two identification rows come out of the equaliser's budget: it is the - -- card's designated slack absorber, so it is what gives up the height. - local id_rows = (cpu_txt and 1 or 0) + (board_txt and 1 or 0) - local fixed_units = 1.00 + 1.13 + temp_n * ROW_F * 1.8 + 0.10 - + id_rows * ROW_F * 1.5 - local S = clamp(math.min(inner.h * 0.96 / (fixed_units + 1.7), - card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72) - local label_size = S * LABEL_F - local big_size = S * BIG_F - local row_size = S * ROW_F - - local y = inner.y - - -- Aggregate load, the headline number. It takes the header line, right - -- aligned like the big value on every other card, with the label top-left. - card.font(cr, card.FONT_HEAVY, big_size, false) - card.rgba(cr, load and card.threshold(load, 25, 75, colors) or colors.label) - card.text_right(cr, inner.x + inner.w, y + big_size, load_txt) - - card.font(cr, card.FONT_MONO, label_size, false) - card.title(cr, inner.x, y + label_size, card.ICON.system, 'CPU', colors) - - -- The hardware this card is about, under the header. Truncated by character, - -- never by byte: a model string can carry a multi-byte character and half of - -- one draws as a replacement box. - -- Two accents rather than one label colour: the rows name two different - -- pieces of hardware, and giving each its own hue separates them at a glance - -- without a prefix like 'CPU:' eating the width. `heading` and `highlight` - -- are the two roles that carry no state meaning, so they can stand for an - -- identity; ok/warning/critical could not, since those say how something is - -- doing and these strings never change. - local id_y = y + big_size + row_size * 0.2 - if cpu_txt or board_txt then - card.font(cr, card.FONT_MONO, row_size, false) - local id_limit = math.max(8, math.floor(inner.w / (row_size * 0.55))) - if cpu_txt then - card.rgba(cr, colors.heading) - card.text(cr, inner.x, id_y, card.truncate(cpu_txt, id_limit)) - id_y = id_y + row_size * 1.5 - end - if board_txt then - card.rgba(cr, colors.highlight) - card.text(cr, inner.x, id_y, card.truncate(board_txt, id_limit)) - id_y = id_y + row_size * 1.5 - end - end - - -- The equaliser: one bar per core, rising from a common baseline. It takes - -- the room left between the header and the RAM/temperature block, so it is - -- the card's slack absorber. - -- Below the identification rows when there are any, otherwise where it was. - local eq_top = (cpu_txt or board_txt) and (id_y + S * 0.10) or (y + big_size + S * 0.30) - local bar_h = S * 0.18 - local step = row_size * 1.8 - -- The floor was a flat 18px. With the identification rows above it the - -- equaliser can genuinely run out of room, and a fixed floor means it keeps - -- height it does not have and the temperature rows draw over it. Zero is a - -- legitimate outcome: the guard below skips the band when it has no height. - local eh = clamp((inner.y + inner.h) - eq_top - (S * 1.13 + temp_n * step), - 0, math.huge) - local n = #pcts - if n > 0 and eh >= 8 then - local gap = math.max(2, inner.w * 0.006) - local bw = (inner.w - gap * (n - 1)) / n - -- Below about 3px a row of sixteen bars is an illegible smear; drop to the - -- aggregate bar instead of drawing one. - if bw >= 3 then - for i, pct in ipairs(pcts) do - card.vbar(cr, inner.x + (i - 1) * (bw + gap), eq_top + eh, bw, eh, - pct / 100, card.threshold(pct, 25, 75, colors), colors) - end - else - card.bar(cr, inner.x, eq_top + eh - bar_h, inner.w, bar_h, (load or 0) / 100, - card.threshold(load or 0, 25, 75, colors), colors) - end - end - local ey = eq_top + eh + S * 0.30 - - -- Memory. MemAvailable, not free: it already excludes reclaimable cache, so - -- it is the figure a person recognises as "used". - local mem = data.mem_info(data.slurp('/proc/meminfo') or '') - if mem then - local frac = mem.used / mem.total - card.font(cr, card.FONT_MONO, row_size, false) - card.rgba(cr, colors.label) - card.text(cr, inner.x, ey, 'RAM') - card.rgba(cr, colors.value) - card.text_right(cr, inner.x + inner.w, ey, - string.format('%s / %s', card.human(mem.used * 1024), card.human(mem.total * 1024))) - ey = ey + S * 0.30 - card.bar(cr, inner.x, ey, inner.w, bar_h, frac, - card.threshold(frac * 100, 25, 75, colors), colors) - ey = ey + bar_h + S * 0.35 - end - - -- Temperatures, each against its own ceiling. - card.font(cr, card.FONT_MONO, row_size, false) - for _, t in ipairs(TEMPS) do - if ey + step > inner.y + inner.h then break end -- out of room: stop - local v = data.sensor(t[1], t[2]) - card.rgba(cr, colors.label) - card.text(cr, inner.x, ey, t[3]) - card.rgba(cr, v and card.threshold(v, t[4], t[5], colors) or colors.label) - card.text_right(cr, inner.x + inner.w, ey, - v and string.format('%d\u{00B0}', v) or '--') - ey = ey + step - end -end - -return M -- cgit v1.2.3