# 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.