# 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** to `/var/lib/pkgtools/packages`. GNU `ls` follows it with or without a trailing slash, so both forms count correctly; `ls -1d` is what would report one entry, by listing the link itself. An earlier draft of this document claimed the slash was load-bearing, from a count taken in an environment where the path did not resolve at all. The count is still verified against a known-good value, because it is the kind of number that looks plausible while being wrong. **`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//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 = , colour = }` 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 `, 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.