aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/specs/2026-09-17-network-slackware-widgets-design.md313
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.