aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-17-network-slackware-widgets-design.md
blob: e295c35cfd59bd4bf911de55ff502afb9dbd54d8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
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.