diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/specs/2026-09-17-network-slackware-widgets-design.md | 313 |
1 files changed, 313 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-09-17-network-slackware-widgets-design.md b/docs/superpowers/specs/2026-09-17-network-slackware-widgets-design.md new file mode 100644 index 0000000..e295c35 --- /dev/null +++ b/docs/superpowers/specs/2026-09-17-network-slackware-widgets-design.md @@ -0,0 +1,313 @@ +# Network and Slackware widgets design + +Date: 2026-09-17 +Status: approved, not yet implemented + +Two new cards and three fixes to existing ones. The cards are `network` (LAN +and public address, with a two-line throughput chart) and `slackware` +(distribution version, package count, kernel, and how long since the last +slackpkg ChangeLog change). The fixes add CPU and motherboard identification to +the `system` card, replace the percentage under each disk ring with free and +total size, and correct a line-state leak in `card.ring`. + +This follows `2026-09-17-system-widgets-design.md`, whose sampler-and-cache +pattern, fluid type convention and colour language carry over unchanged. Read +`DESIGN.md` before implementing any of it. + +## What the old config did + +The previous text-mode conky config +(`~/.config/conky/conky.conf.pre-udt-symlink`, not in this repository) already +had a network section, and it is the source of several decisions here: + + ${addr br0} LAN address + ${execi 300 curl ipinfo.io/ip} public address, 5 minutes + ${downspeedgraph br0 50,245} two separate graphs, side by side + ${upspeedgraph br0 50,245} + ${downspeed br0}k/s ${upspeed br0}k/s + ${exec cat /etc/slackware-version} + +Glyphs carried over from it: network, LAN, globe, download, upload, power. +Every codepoint is re-rendered and looked at before it is trusted, per +`DESIGN.md`: a wrong-but-present codepoint draws a plausible neighbour rather +than failing visibly. + +Two things change. The two separate graphs become one chart with two lines, as +requested. The public address is fetched every 30 minutes rather than every 5: +a residential address is stable for days, and 5 minutes is 288 requests a day +to someone else's service for a value that almost never changes. + +## Host bindings + + br0 bridge interface, the one to graph + /var/log/packages/ symlink to /var/lib/pkgtools/packages + /var/lib/slackpkg/ChangeLog.txt mtime is the "last update" marker + / birth time is the install date + +Observed values at design time, for sanity-checking an implementation: +2817 packages, `Slackware 15.0+`, kernel 6.18.50, board `Gigabyte Technology +Co., Ltd.` + `X870 EAGLE WIFI7`, CPU `AMD Ryzen 7 9700X 8-Core Processor`. + +**`/var/log/packages` is a symlink.** `ls -1 /var/log/packages | wc -l` returns +**1**: it lists the link itself. The trailing slash in +`ls -1 /var/log/packages/` is load-bearing and must not be removed as +cosmetic. This was caught only because the count was checked against a known +value. + +**`br0` is a bridge, so its counters are not internet throughput.** Traffic +between VMs and the host crosses it without reaching the router, so a local +copy spikes the graph. The old config had the same property and it was +acceptable; the interface name stays a single constant at the top of the widget +so it can be changed in one edit. + +## The network card + +### Reading the rate + +`/sys/class/net/<IFACE>/statistics/rx_bytes` and `tx_bytes`, read every draw. +No subprocess: this is the same reasoning that memoized `hwmon_dir`. + +The counters are cumulative, so a rate needs two samples, exactly like CPU +load. `data.new_rate_counter()` mirrors `new_cpu_counter()`: it holds the +previous byte count and sample time, returns bytes per second, and returns +`nil` on the first call rather than a fabricated zero. + +Three cases it must survive, because each produces a number that would wreck +the shared autoscale for the whole window: + +- **Counter wrap or interface reset.** A negative delta clamps to zero, never + to a huge positive from unsigned arithmetic. +- **Zero elapsed time.** `os.time()` is whole seconds against a 2 second draw + interval, so two samples can report the same second. Fall back to + `conky_info.update_interval` rather than dividing by zero. +- **A missing interface.** Both reads return nil, the card draws its notice. + +### History + +One ring buffer per direction, length derived from the plot's pixel width: one +sample per column, so the chart never interpolates and the window is however +wide the card is (about 10 minutes at 300px and a 2 second interval). The +buffer is module state, allocated on first draw and reallocated when the width +changes, preserving what it can. A conky restart starts it empty and it fills +left to right; a partially full buffer draws only the samples it has. + +### The chart + +One plot area, both series on a common baseline, drawn as polylines. + +**Shared autoscale**, to the larger peak of either series in the window, so the +two lines stay honest against each other: a 200kB/s upload must not look like a +40MB/s download. The scale has a floor so an idle link is a flat line at the +bottom rather than amplified noise. + +The window peak is printed, because a full-height line means nothing in +absolute terms without it. + +Below the chart, the live rates: download and upload, each with its glyph, each +in its series colour so the line and the number are unmistakably the same +thing. + +`card.plot(cr, x, y, w, h, series, colors)` is the new primitive: a list of +`{ values = <ring>, colour = <rgb> }` and a shared maximum, stroked as +polylines. It **restores the line width and cap it sets**, which is the bug +already recorded against `card.ring` (see Fixes below). + +### Addresses + +The LAN address comes from `ip -4 addr show <IFACE>`, memoized on the first +success. It is retried while nil, because a bridge may not be up when conky +starts; once found it is not re-read, since it cannot change without an event +this dashboard does not watch. + +The public address comes from `bin/pubip-sample.sh` via a cache file, `execi +1800`. The sampler curls `ipinfo.io/ip` with a short timeout, validates the +response is an address before writing, and writes an epoch alongside it. A +response that is not an address (a captive portal, an error page, a rate-limit +message) is discarded rather than displayed. + +**Staleness is shown, not hidden.** Past four hours, eight sampling intervals, +the card prints `--` rather than an address that may no longer be yours. A +wrong address displayed confidently is worse than no address. + +### Repository constraint + +`AGENTS.md` forbids LAN addresses, hostnames and usernames in committed files. +The card displays them at runtime, which is fine, but **fixtures and tests use +`192.0.2.x` (TEST-NET-1) and never this host's real values.** No test asserts a +real address. + +## The Slackware card + +### Sampler + +`bin/slackware-sample.sh`, `execi 900`, writes `~/.cache/udt/slackware.txt`: + + version Slackware 15.0+ + packages 2817 + changelog 1789548716 + birth 1754061286 + kernel 6.18.50 + +Five reads, no formatting in the shell: `cat /etc/slackware-version`, +`ls -1 /var/log/packages/ | wc -l` (trailing slash, see Host bindings), +`stat -c %Y /var/lib/slackpkg/ChangeLog.txt`, `stat -c %W /`, `uname -r`. + +**Epochs as integers, never reconstructed dates.** The shell function this +replaces (`~/.profile.d/qar.sh`) parses `ls -l` output and hardcodes the year +into the rebuilt date string, which breaks every January and on any file older +than six months, when `ls -l` prints a year instead of a time and the field +offsets shift. `stat -c %Y` has neither problem. + +Same discipline as the other samplers: `umask 077`, write to a temp file, +`mv -f` into place, so a half-written cache is never read. + +### Parser + +`data.kv_parse(text)` splits `key value` lines into a table with string values. +Unknown keys pass through; missing keys read as nil. Generic and named because +both the widget and its test use it. + +### The card + +Header label `SLACKWARE`. The big top-right value is the **age of the +ChangeLog**: hours under 48, days beyond, so the string stays short at any cell +size. + +Colour by `card.threshold(hours, 24, 168)`: green under a day, amber to a week, +red past it. This extends the binary green/red of `qar.sh` into the board's +three-state language rather than introducing a fourth convention. + +Rows, label-left and value-right per `DESIGN.md`: + +| Row | Value | +|---|---| +| `VERSION` | `15.0+`, with the leading `Slackware ` stripped since the header says it | +| `PACKAGES` | `2817` | +| `KERNEL` | `6.18.50` | +| `AGE` | days since the root filesystem was created | + +### Two failure modes, deliberately distinct + +- **No cache file**: the `no cache data` notice plus the sampler's name, as the + cache card does. The sampler has not run. +- **Cache present, `changelog` key missing or unparseable**: draw every row + that is present and `--` for the age. The sampler ran; the ChangeLog is what + is missing. Collapsing these two into one message would send the reader to + the wrong problem. + +### Clock skew + +A ChangeLog mtime in the future yields a negative age. Clamp to zero and print +`0h`. Not hypothetical: the mtime observed during design was in the future +relative to the design date. + +## System card: CPU and motherboard + +`data.cpu_model(cpuinfo)` and `data.board_name(vendor, name)`, pure string +functions over file contents, tested against fixtures. + +Stripping rules, applied in order, each general rather than a special case for +this machine: + +- **CPU**: drop a leading vendor word, drop `(R)` and `(TM)`, drop a trailing + `N-Core Processor` or `CPU @ N.NGHz`, collapse whitespace. + `AMD Ryzen 7 9700X 8-Core Processor` becomes `Ryzen 7 9700X`. +- **Board**: drop corporate suffixes (`Technology Co., Ltd.`, `Inc.`, + `Corporation`, `CO.,LTD`) from the vendor, then join vendor and name. + `Gigabyte Technology Co., Ltd.` + `X870 EAGLE WIFI7` becomes + `Gigabyte X870 EAGLE WIFI7`. + +Both memoized at module level. Neither can change without a reboot, and +re-reading `/proc/cpuinfo` every two seconds for a constant is waste. + +`board_vendor` and `board_name` are world-readable under +`/sys/devices/virtual/dmi/id/`, unlike the serial fields, so this needs no +privilege. + +Placed as two rows directly under the header, in the `label` colour at row +size, above the equaliser. They join the `card.fit_unit` group list so the +width fit accounts for them, and the equaliser's height budget loses two rows, +since it is the card's designated slack absorber. A name that still overruns is +truncated by **display width, not byte count**. + +**Stated risk.** The system card is 3x5 cells and already carries a header, a +sixteen-bar equaliser, a RAM row and five temperature rows. Two more rows may +crowd it. The offscreen renderer will not reveal this because it crops to a +single card; the live board is the judge. If it crowds, the fallback is one row +(CPU only) or moving the pair to their own card. + +## Disks: free and total under each ring + +The percentage under each ring is replaced by two stacked rows: **free space** +on top in the threshold colour, **total size** beneath it in the plain `value` +colour. One coloured number per column, as the other cards read. + +The ring already encodes the percentage as an angle. A number repeating it +earns nothing and costs a row in a column about 75px wide. + +**Free space comes from the cache, not from arithmetic.** `df -P -B1` prints an +available column that `df_parse` currently discards. The parser gains `avail` +and the widget prints it. Deriving it as `size - used` would be wrong: the two +differ by the root-reserved blocks, typically 5%, which on `/` is tens of +gigabytes that are not actually available. Existing fixtures already carry the +column, so this is a parser change and needs no sampler change. + +Both rows are sized from the ring radius and clamped, and the ring's vertical +budget loses one row's height so the pair fits where the percentage was. The +bar fallback, used below the radius where a ring reads, keeps its existing +`used / size` line. + +This closes the only item in `TODO.md`. + +## Fixes folded in + +Two items from the previous session's review list, included because this work +touches the same code: + +- **`card.ring` restores the line cap but not the line width it sets.** A later + stroke inherits the ring's width. `card.plot` sets both, so both primitives + save and restore both, and the pattern is fixed once rather than twice. +- **Truncation by byte count can split a multi-byte UTF-8 name.** Both new + cards truncate, so truncation becomes display-width-aware and `cache.lua` + adopts it. + +The remaining polish items from that list stay untouched. + +## Tests + +`test/test_data.lua` gains, with fixtures under `test/fixtures/`: + +- `new_rate_counter`: first call nil, normal delta, counter wrap, interface + reset, zero elapsed time. +- `kv_parse`: well-formed input, missing key, malformed line. +- `cpu_model` and `board_name`: the strings above plus an Intel form, to prove + the rules are rules and not one machine's special case. +- `df_parse`: the new `avail` field, and the digit-bearing device row `fs[3]` + that the existing suite does not assert. + +Fixtures use generic hardware strings and TEST-NET addresses. + +Visual checks, per widget, before either card meets the board: + + lua test/render.lua network 16 12 4 3 /tmp/net.png + lua test/render.lua slackware 16 12 2 3 /tmp/slack.png + +Both samplers are runnable by hand and idempotent. The package count is +verified against a known-good value rather than assumed, given how the symlink +behaves. + +Full suite: `lua test/test_data.lua && lua test/test_layout.lua && lua +test/test_weather.lua`. + +## Placement + +Provisional, to be retuned live: `network` under `system`, `slackware` beside +it at the bottom. There is no settled arrangement for the board yet, and the +layout table in `dashboard.lua` is the user's to edit. + +## Out of scope + +Per-process network usage, an interface picker, IPv6, packet counts, disk I/O +rates, and a slackpkg "updates available" count. The last needs a +`slackpkg check-updates` run, which is a network operation against a mirror and +a different kind of thing from reading a timestamp. |
