diff options
| -rw-r--r-- | DESIGN.md | 24 | ||||
| -rw-r--r-- | README.md | 138 | ||||
| -rwxr-xr-x | bin/disks-sample.sh | 40 | ||||
| -rwxr-xr-x | bin/pubip-sample.sh | 48 | ||||
| -rw-r--r-- | conky.conf.in | 2 | ||||
| -rw-r--r-- | dashboard.lua | 8 | ||||
| -rw-r--r-- | lib/card.lua | 145 | ||||
| -rw-r--r-- | lib/data.lua | 397 | ||||
| -rw-r--r-- | test/fixtures/df_output | 7 | ||||
| -rw-r--r-- | test/fixtures/ip_addr | 3 | ||||
| -rw-r--r-- | test/fixtures/proc_cpuinfo | 12 | ||||
| -rw-r--r-- | test/fixtures/proc_cpuinfo_intel | 6 | ||||
| -rw-r--r-- | test/fixtures/proc_meminfo | 7 | ||||
| -rw-r--r-- | test/fixtures/proc_stat | 5 | ||||
| -rw-r--r-- | test/fixtures/proc_stat_percore | 11 | ||||
| -rw-r--r-- | test/fixtures/temp1_input | 1 | ||||
| -rw-r--r-- | test/test_data.lua | 263 | ||||
| -rw-r--r-- | widgets/disks.lua | 150 | ||||
| -rw-r--r-- | widgets/gpu.lua | 121 | ||||
| -rw-r--r-- | widgets/network.lua | 181 | ||||
| -rw-r--r-- | widgets/system.lua | 198 |
21 files changed, 32 insertions, 1735 deletions
@@ -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. @@ -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/<pid>/fdinfo/<fd>`, 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 = <array>, colour = <rgb> }. `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 <iface>` 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 = { <calendar> = <khal colour name> }, @@ -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:<tab>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: <BROADCAST,MULTICAST,UP,LOWER_UP> 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: <BROADCAST,MULTICAST> 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 |
