diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-17 18:46:47 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-17 18:46:47 +0200 |
| commit | 388943f22861fb50951beeca11221260f37222ca (patch) | |
| tree | 8a4c5b435db20ebb9a4749ff6f0ffbc580f57e69 | |
| parent | 856abdc38495bbe304b690a98fa89491a1a6fce6 (diff) | |
| download | conky-theme-udt-388943f22861fb50951beeca11221260f37222ca.tar.gz conky-theme-udt-388943f22861fb50951beeca11221260f37222ca.zip | |
docs: design the network and slackware widgets
Two new cards and three fixes to existing ones.
The network card reads the interface byte counters directly and keeps its
own history, one sample per pixel column, so the chart never interpolates.
Both series share one autoscale: drawn independently, a 200kB/s upload would
look like a 40MB/s download. The public address moves from the old config's
5 minute poll to 30 minutes, since a residential address is stable for days.
The slackware card takes its numbers from a sampler rather than the draw
hook, and stores epochs instead of reconstructed dates. The shell function it
replaces parses ls -l output and hardcodes the year, which breaks every
January and on any file older than six months.
Records two things found while checking real values against the design:
/var/log/packages is a symlink, so counting it without a trailing slash
reports 1 package rather than 2817, and the ChangeLog mtime on this machine
currently reads in the future, so the age arithmetic has to survive a
negative difference.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -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. |
