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
|
# System widgets design
Date: 2026-09-17
Status: approved, not yet implemented
Four cards covering the machine itself: CPU and memory with every temperature,
the GPU, filesystem capacity, and the user cache.
The original request was three widgets (CPU/RAM/temps, GPU, disks). Cache
became a fourth during design: it is a different kind of data on a much slower
refresh cycle, and sharing a card with the filesystems would have made the
densest card in the dashboard.
This follows the v1 design (`2026-09-16-conky-lua-dashboard-design.md`) and the
weather widget (`2026-09-17-weather-widget-design.md`), whose cache-and-read
pattern and fluid-widget convention both carry over unchanged.
## The four cards
| Widget | Shows | Source |
|---|---|---|
| `system` | CPU load, 16 per-core bars, RAM used/total, and every temperature: Tctl, Tccd1, motherboard x2, NVMe | `/proc/stat`, `/proc/meminfo`, hwmon |
| `gpu` | Arc B580 package temp, VRAM temp, fan RPM | hwmon `xe` |
| `disks` | `/`, `/home`, `/data`, plus one NFS mount per server, with usage bars | cache file |
| `cache` | `~/.cache` total and its four largest subdirectories | cache file |
## Host bindings
These are specific to this machine and will not port. They are stated here
because the alternative is rediscovering them from a wrong reading.
k10temp temp1 Tctl CPU package
temp3 Tccd1 CCD die
nvme temp1 Composite WD_BLACK SN850X
xe temp2 pkg Arc B580 package
temp3 vram Arc B580 memory
fan1 Arc B580 fan RPM
gigabyte_wmi temp2, temp3 motherboard, X870 Eagle WiFi 7
**hwmon is globbed by its `name` file, never by index.** Indices drift across
kernel and hardware reorders and a stale one silently reports a different chip.
`lib/data.lua` already has `hwmon_dir()` for this.
The `gigabyte_wmi` chip exposes five unlabelled temperatures. The old conky
config showed `temp2` and `temp3`, chosen empirically; that choice is carried
over, and the uncertainty carried over with it. They are labelled "board"
rather than given a specific meaning they may not have.
`Tccd1` is read from sysfs (`temp3_input`), not through the `sensors` command
the old config shelled out to. Same number, no subprocess.
### What the hardware does not expose
**The Arc B580 reports no utilisation.** The `xe` driver exposes no
`gpu_busy_percent`, unlike `amdgpu`. `intel_gpu_top` refuses the device
("Detected Xe device which is not supported"), and `gputop` can read per-engine
load but prints per-process rows with ANSI escapes and would need a sampler and
a parser for a format that is not a stable interface. The GPU card therefore
shows temperatures and fan speed, and no load bar. This is honest about the
hardware rather than inventing a number.
The AMD integrated GPU does expose `gpu_busy_percent`, but is deliberately not
shown: the discrete card is the one in use.
**`sda` reports no temperature.** `/data` sits on a WD spinning disk with no
hwmon chip; reading its SMART temperature would need `smartctl` (not installed)
running as root, and would spin up a sleeping disk on every poll. The NVMe
composite temperature is freely readable and is shown; `sda` simply has none.
## Data
### Additions to `lib/data.lua`
Most of what these widgets need already exists and is tested: `cpu_times`,
`mem_info`, `millidegrees`, `slurp`, `hwmon_dir`, `new_cpu_counter`. Three
functions are added, in the same shape as the rest, taking file *contents* as a
string so the tests need no filesystem:
- `per_cpu_times(stat)` — the `cpu0..cpuN` lines rather than only the
aggregate, returning a list of `{total, idle}`. The existing
`new_cpu_counter` then works per core with no change, which is exactly why it
was written as an instance holding its own previous sample.
- `sensor(chip, file)` — `hwmon_dir` plus `millidegrees`, so a widget writes
`data.sensor('k10temp', 'temp1_input')` instead of building paths. Returns
nil when the chip or file is absent.
- `df_parse(text)` and `du_parse(text)` — parse the two cache files below.
Sensor bindings stay in the widgets. They are host-specific, and `data.lua` is
the part that is not.
### The two sampler scripts
Same pattern as `bin/weather-fetch.sh`, for the same reason: the draw hook
never runs a subprocess and never blocks.
bin/disks-sample.sh df -P over the local mounts and the two NFS mounts
-> ~/.cache/udt/disks.txt every 60s
bin/cache-sample.sh du -sh ~/.cache and its children
-> ~/.cache/udt/cachesize.txt every 900s
Both write a temp file in the same directory and rename over the target, under
`umask 077`, exactly as the weather fetch does. Rename is atomic within a
filesystem; the widget reads on the 2-second draw cadence and would otherwise
see a half-written file.
`conky.text` gains two more `${execi}` entries alongside the weather one. That
block renders nothing (Cairo covers it) but still fires, which was verified with
a probe config before the weather widget relied on it.
**Why the disks sampler exists at all**: `statfs` on an NFS path blocks when the
server is unreachable. Called from the draw hook that would freeze the whole
dashboard, which is the one failure this project has worked hardest to avoid.
In the sampler it costs a stale cache and nothing else, and the widget shows the
age exactly as the weather card does.
**Why the cache sampler is separate and slower**: `du -sh ~/.cache` walks the
tree and takes about 100ms warm. That is fine every 15 minutes and unthinkable
every 2 seconds. The old conky config used 900s for the same reason.
### NFS: one mount per server
Four NFS mounts are configured, but they are two filesystems:
Library, Slackware one server, same export, identical figures
shared, backup_danix another server, same export, identical figures
Showing all four repeats each number twice. The card shows `Library` and
`shared`, one per server.
## Drawing
All four follow the fluid convention documented in the README: sizes derive
from `inner.w` with clamps, the slack is distributed deliberately, and an
element that cannot fit drops out rather than overlapping.
### One colour language across all four cards
Every quantity that has a comfortable range and an uncomfortable one is
coloured the same way, so a glance at any card reads without learning a new
scheme:
| State | Role | Meaning |
|---|---|---|
| fine | `ok` | below the first threshold |
| busy | `warning` | between the thresholds |
| nearly full / hot | `critical` | above the second |
**`warning` is new to the dashboard palette.** `dashboard.lua`'s `palette()`
exposes `ok` and `critical` but no middle colour. `udt-palette` already
resolves `warning` in every scheme: it maps `yellow`, or Nord's
`aurora_yellow`, to that role globally, the same way it maps `red` to
`critical` even though `[conky]` never lists it, and other generators already
read `res['warning']`.
Three places, one line each, none of them a scheme file:
1. `bin/udt-palette` (UDT repo), `gen_conky`: add `"warning"` to the tuple of
roles it substitutes. `critical` was added to that same tuple for the error
overlay, so this is the established way.
2. `conky.conf.in`: a `color8 = '@WARNING@'` line.
3. `dashboard.lua`'s `palette()`: `warning = hex(CFG.color8)`.
Scheme switching keeps working untouched, and every existing scheme gets the
colour for free.
Thresholds by kind:
- **Filesystems and cache**: 25% and 75%, as requested.
- **CPU load**: the same 25/75, so a core at rest, a core working and a core
pinned are distinguishable at a glance.
- **Temperatures**: per sensor, because 70C is unremarkable for a CPU and
alarming for an NVMe. Each carries its own pair in the widget's binding
table, alongside the hwmon path it already needs:
Tctl 75 / 90
Tccd1 75 / 90
NVMe 60 / 70
Arc pkg 75 / 85
Arc VRAM 80 / 90
board 60 / 70
These are starting values, host-specific exactly as the hwmon bindings are,
and sit in one table so they are easy to retune once real numbers under load
are known.
### Shared primitives in `lib/card.lua`
`card.bar(cr, x, y, w, h, frac, colour)` — a track with a filled portion.
`card.ring(cr, cx, cy, r, frac, colour, colors)` — an open circle with an arc
covering `frac` of it, after `idea2.png`: the arc starts at twelve o'clock and
sweeps clockwise, over a dim full-circle track, with room at the centre for a
glyph.
`card.threshold(frac, warn, crit, colors)` — returns the colour for a value
against two thresholds. One function so all four widgets agree, and so the
rule is stated once rather than re-derived per card.
### The per-core row as an equaliser
Sixteen vertical bars across the card, each rising from a common baseline with
its core's load, coloured by the same thresholds. Reads as a level meter: idle
cores sit low and green, a compile lights the whole row amber to red.
Bar width and gap derive from the cell. Below the width where sixteen bars
would each be thinner than about 3px the widget drops to the aggregate bar
alone rather than drawing an illegible grey smear, which is the same
drop-rather-than-crush rule the weather arc follows.
### The disks as rings
After `idea2.png`: one ring per filesystem, each with an icon at its centre
naming what it is (a drive glyph for local, a network glyph for NFS), the arc
showing usage and coloured by threshold. The mount point and percentage sit
beside or beneath depending on how many fit across the card width.
Rings wrap to as many rows as the cell allows and, past the point where a ring
would be too small to read, the widget falls back to the labelled horizontal
bars `card.bar` already provides.
### Cache
The total as a heading, then the four largest subdirectories. The largest is
drawn in `warning` (or `critical` if it dominates, over half the total) while
the rest stay in `value`, so the thing worth deleting is the thing that catches
the eye.
## Staleness and failure
Each widget draws its card chrome and a notice rather than vanishing, exactly
as the weather card does, because an empty cell is indistinguishable from a
crashed widget.
- A missing sensor shows `--`, not 0. Zero degrees is a plausible reading.
- A missing cache file shows `no data` and the sampler that should have written
it.
- A cache older than ten times its sampling interval shows a dim age marker
beside the heading, so an unreachable NFS server or a dead sampler is visible
rather than silently showing yesterday's numbers.
> **Implementation note (deferred): the age marker is not drawn.** The
> requirement above is deliberately unimplemented in this wave. The cache
> formats and their parser fixtures are fixed by the plan, and the widgets
> already implement the absent-file case (`no cache data` plus the sampler
> name). Adding the marker means either embedding an epoch timestamp in each
> cache file, which would disturb every fixture and the two parsers, or
> stamping the file's mtime, which conky's Lua cannot stat: `lfs` is not
> available in the environment. The accepted path when it is picked up is an
> extra epoch line that the parser learns to skip, leaving the existing fields
> and fixtures untouched; the widget then compares it against the sampling
> interval and draws a dim age marker beside the heading.
## Tests
`test/test_data.lua` extends with fixtures under `test/fixtures/`:
- a `/proc/stat` with per-core lines, checking `per_cpu_times` returns one entry
per core and that `new_cpu_counter` gives a sane percentage per core across
two samples
- a `df -P` capture including the NFS rows, checking `df_parse` handles an NFS
device field containing a colon (`server:/export`, which a naive split on
punctuation would break), a mount at 100%, and a trailing blank line. `df -P`
guarantees one line per mount with the mountpoint last, which is why the
parser takes the last field rather than the sixth
- a `du -sh` capture, checking `du_parse` orders by size and handles the
human-readable suffixes rather than sorting them as strings: `1.1G` must
outrank `245M`, which a string sort gets backwards. `K`, `M`, `G` all occur
in the current cache and `T` occurs in `df` output, so all four are converted
- truncated and empty inputs to both parsers returning an empty table rather
than raising
Run with the others:
lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua
The cards themselves are verified by rendering them offscreen at several cell
sizes and looking at the PNG, then in the live dashboard.
## Files
| Path | Change |
|---|---|
| `widgets/system.lua` | new |
| `widgets/gpu.lua` | new |
| `widgets/disks.lua` | new |
| `widgets/cache.lua` | new |
| `bin/disks-sample.sh` | new |
| `bin/cache-sample.sh` | new |
| `lib/data.lua` | `per_cpu_times`, `sensor`, `df_parse`, `du_parse` |
| `lib/card.lua` | `card.bar` |
| `test/test_data.lua` | fixtures and assertions for the new parsers |
| `conky.conf.in` | two more `${execi}` entries |
| `dashboard.lua` | four rows in the `layout` table |
| `README.md` | the widgets, the samplers, the host bindings |
## Out of scope
**Disk I/O rates.** The old config showed read/write throughput via conky's
`${diskio}`, which is a built-in the Lua side does not get; it would mean
parsing `/proc/diskstats` and holding per-draw deltas. Worth doing, but it is
its own piece of work and the card is already full.
**GPU utilisation.** See above: the hardware does not expose it through any
interface stable enough to build on.
**Network.** Named in the original plan, still unspecified, and unaffected by
any of this.
|