From e55896ce41d6165105671da5b6f33813106ef1b8 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 30 Sep 2026 19:56:47 +0200 Subject: feat: add an updates card showing nvtable's package table Moves the nvchecker/nvtable table the login shell printed onto the dashboard, as a wide card at the bottom centre. bin/updates-sample.sh caches `nvtable -C -E` every ten minutes and starts nvchecker, fully detached, at most every 30 minutes. The widget parses the cache and colours it in the board's language: installed behind upstream is critical, a shipped .info behind is a warning, with nvtable's status dots per row and a legend on the bottom line. The mouse wheel scrolls rows that do not fit. Co-Authored-By: Claude Opus 5.5 --- README.md | 23 +++++- bin/updates-sample.sh | 39 +++++++++ conky.conf.in | 2 +- dashboard.lua | 3 + lib/card.lua | 1 + lib/data.lua | 39 +++++++++ test/fixtures/updates_cache | 8 ++ test/test_data.lua | 17 ++++ widgets/updates.lua | 195 ++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 324 insertions(+), 3 deletions(-) create mode 100755 bin/updates-sample.sh create mode 100644 test/fixtures/updates_cache create mode 100644 widgets/updates.lua diff --git a/README.md b/README.md index a78371e..59e5797 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ in the source, so no real location appears in committed files. ## System widgets -Six cards cover the machine itself: +Seven cards cover the machine itself: - **System**: CPU load as a per-core equaliser, the CPU and motherboard model as two rows under the header, RAM used/total, and every temperature (CPU @@ -78,12 +78,14 @@ Six cards cover the machine itself: lines on one chart. See below. - **Slackware**: distribution version, package count, kernel, install age, and time since the last `slackpkg` ChangeLog update. See below. +- **Updates**: nvtable's table of tracked packages behind upstream, scrolled + with the mouse wheel. See below. The disks and cache cards read a cache file that a sampler writes; they never call `du` or `statfs` themselves. `statfs` on an unreachable NFS path blocks, and the disks sampler exists so that a dead server costs a stale cache rather than a frozen draw. Sampling is scheduled by conky itself: `conky.text` carries -six `${execi}` entries, even though that block renders nothing under Cairo. +an `${execi}` per sampler, even though that block renders nothing under Cairo. ${execi 900 ~/.config/conky/bin/weather-fetch.sh} ${execi 60 ~/.config/conky/bin/disks-sample.sh} @@ -92,6 +94,7 @@ six `${execi}` entries, even though that block renders nothing under Cairo. ${execi 900 ~/.config/conky/bin/slackware-sample.sh} ${execi 300 ~/.config/conky/bin/calendar-sample.sh} ${execi 1800 ~/.config/conky/bin/immich-fetch.sh} + ${execi 600 ~/.config/conky/bin/updates-sample.sh} The disks sampler runs once a minute; the cache, Slackware and public-IP samplers run every 15, 15 and 30 minutes respectively, because `du -sh @@ -110,6 +113,7 @@ To run a sampler by hand: ./bin/pubip-sample.sh; echo "exit: $?" ./bin/slackware-sample.sh; echo "exit: $?" ./bin/calendar-sample.sh; echo "exit: $?" + ./bin/updates-sample.sh; echo "exit: $?" All print nothing and exit 0 on success, and all write a temp file and rename it over the target, so the widget never reads a half-written file. @@ -216,6 +220,21 @@ The key sits in a plain file, like the weather key, because the fetch runs unattended from conky: reading it from a GPG-backed store would raise a pinentry whenever the agent's cache has expired. +### Updates + +nvtable's table of tracked packages that need action: where each is +installed, the version its SlackBuild ships and, when that is not it, the +upstream release. The big value is how many installed packages are behind +upstream. Installed versions read red when behind and green when caught up, +a shipped `.info` behind upstream reads as a warning. Rows that do not fit +scroll with the mouse wheel, with a thin scrollbar at the right edge. + +`bin/updates-sample.sh` runs every ten minutes and caches `nvtable -C -E` +in `~/.cache/udt/updates.txt`. It also starts `nvchecker` in the background +at most every 30 minutes, detached so conky never waits on the network; its +results show up on the next sample. `nvtable` is looked up in `~/bin` too, +since conky launched by the compositor lacks the login shell's `PATH`. + ### Host bindings These hwmon bindings, and the ceilings beside them, are specific to this machine diff --git a/bin/updates-sample.sh b/bin/updates-sample.sh new file mode 100755 index 0000000..0c9ee3a --- /dev/null +++ b/bin/updates-sample.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Sample nvtable's package-update table into a cache file, for +# widgets/updates.lua. Replaces the table the login shell used to print. +# +# nvchecker itself is slow (one network request per stanza), so it is started +# in the background at most every 30 minutes, fully detached: conky's execi +# waits for stdout to close, and a child holding it would stall the dashboard. +# nvtable then reads whatever results the last completed run left, so a fresh +# nvchecker run shows up on the next sample. + +set -u + +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt" +CACHE="$CACHE_DIR/updates.txt" +STAMP="$CACHE_DIR/nvchecker.stamp" +NVCHECK_EVERY=1800 + +# conky launched from the compositor does not inherit the login shell's PATH. +PATH="$HOME/bin:$HOME/.local/bin:$PATH" + +command -v nvtable >/dev/null || { echo "updates-sample: nvtable not found" >&2; exit 1; } + +mkdir -p "$CACHE_DIR" + +if command -v nvchecker >/dev/null; then + last=$(cat "$STAMP" 2>/dev/null || echo 0) + now=$(date +%s) + if (( now - ${last:-0} >= NVCHECK_EVERY )); then + echo "$now" > "$STAMP" + setsid nvchecker /dev/null 2>&1 & + fi +fi + +TMP="$CACHE.tmp.$$" +trap 'rm -f "$TMP"' EXIT + +# -C and -E: plain ASCII, which the widget parses and colours itself. +# Keep the old cache when nvtable fails, rather than replacing it with nothing. +nvtable -C -E > "$TMP" 2>/dev/null && mv -f "$TMP" "$CACHE" diff --git a/conky.conf.in b/conky.conf.in index 24bc86f..a0b354e 100644 --- a/conky.conf.in +++ b/conky.conf.in @@ -56,4 +56,4 @@ conky.config = { -- samplers, and tying it to conky means nothing fetches while the dashboard is -- down. breaktimer runs at 5s, its daemon's own tick, so the countdown advances -- in the steps the daemon counts in. -conky.text = [[${execi 900 ~/.config/conky/bin/weather-fetch.sh}${execi 60 ~/.config/conky/bin/disks-sample.sh}${execi 900 ~/.config/conky/bin/cache-sample.sh}${execi 1800 ~/.config/conky/bin/pubip-sample.sh}${execi 900 ~/.config/conky/bin/slackware-sample.sh}${execi 300 ~/.config/conky/bin/calendar-sample.sh}${execi 5 ~/.config/conky/bin/breaktimer-sample.sh}${execi 1800 ~/.config/conky/bin/immich-fetch.sh}]] +conky.text = [[${execi 900 ~/.config/conky/bin/weather-fetch.sh}${execi 60 ~/.config/conky/bin/disks-sample.sh}${execi 900 ~/.config/conky/bin/cache-sample.sh}${execi 1800 ~/.config/conky/bin/pubip-sample.sh}${execi 900 ~/.config/conky/bin/slackware-sample.sh}${execi 300 ~/.config/conky/bin/calendar-sample.sh}${execi 5 ~/.config/conky/bin/breaktimer-sample.sh}${execi 1800 ~/.config/conky/bin/immich-fetch.sh}${execi 600 ~/.config/conky/bin/updates-sample.sh}]] diff --git a/dashboard.lua b/dashboard.lua index 3c75f83..18ddf80 100644 --- a/dashboard.lua +++ b/dashboard.lua @@ -37,6 +37,9 @@ local layout = { -- Centred on the board. Twice as many rows as columns because cells are -- about twice as wide as tall, so this span comes out square. { widget = 'immich', col = 5.5, row = 1, w = 4, h = 6 }, + -- Bottom centre, in the open band between breaktimer and cache. Wide + -- because nvtable's rows are; it scrolls when they outgrow the height. + { widget = 'updates', col = 6, row = 7, w = 6, h = 6 }, } -- ========================================================================== diff --git a/lib/card.lua b/lib/card.lua index 2313141..dde4121 100644 --- a/lib/card.lua +++ b/lib/card.lua @@ -133,6 +133,7 @@ M.ICON = { breaktimer = '\u{F13AB}', -- timer calendar = '\u{F0E17}', -- calendar-month immich = '\u{F02F9}', -- image-multiple + updates = '\u{F03D5}', -- package-up } -- The gap between a title's icon and its text. One constant so the measured diff --git a/lib/data.lua b/lib/data.lua index c66aa42..4b31f43 100644 --- a/lib/data.lua +++ b/lib/data.lua @@ -631,4 +631,43 @@ function M.immich_parse(text) return out end +-- nvtable's plain table (`nvtable -C -E`), as bin/updates-sample.sh caches it: +-- +-- [personal] foo-bin 1.14.9 -> 1.14.9 !! ~ 1.14.10 +-- -> [~] [upstream] +-- +-- followed by a summary line with the counts. The marker is '!!' (installed +-- behind upstream), 'ok' or '-' (not installed); '~' flags the shipped .info +-- as behind. Upstream appears only when shipped does not already show it. +-- +-- Returns the rows as { repo, name, installed, shipped, upstream, behind, +-- bump } with '--' turned into nil, plus .upgrade/.bump/.current/.missing +-- counts, or nil when the text has neither rows nor a summary. +function M.nvtable_parse(text) + if type(text) ~= 'string' then return nil end + local out, seen = {}, false + local function v(s) return s ~= '--' and s or nil end + for line in text:gmatch('[^\n]+') do + local repo, name, inst, ship, rest = + line:match('^%s*%[(.-)%]%s+(%S+)%s+(%S+)%s+%->%s+(%S+)%s*(.*)$') + if repo then + local mark, tail = rest:match('^(%S*)%s*(.*)$') + local bump = tail:match('^~') ~= nil + if bump then tail = tail:gsub('^~%s*', '') end + out[#out + 1] = { + repo = v(repo), name = name, installed = v(inst), shipped = v(ship), + upstream = tail:match('^(%S+)'), behind = mark == '!!', bump = bump, + } + seen = true + elseif line:match('to upgrade') then + out.upgrade = tonumber(line:match('(%d+) to upgrade')) + out.bump = tonumber(line:match('(%d+) repos? to bump')) + out.current = tonumber(line:match('(%d+) current')) + out.missing = tonumber(line:match('(%d+) not installed')) or 0 + seen = true + end + end + return seen and out or nil +end + return M diff --git a/test/fixtures/updates_cache b/test/fixtures/updates_cache new file mode 100644 index 0000000..130dda0 --- /dev/null +++ b/test/fixtures/updates_cache @@ -0,0 +1,8 @@ + [personal] foo-bin 1.14.9 -> 1.14.9 !! ~ 1.14.10 + [SBo] bar 2.1.278 -> 2.1.201 !! 2.1.284 + [personal] baz 3.16.30 -> 3.16.31 !! + [--] qux 0.12.1 -> -- !! 0.12.1_beta.2 + [my-sbo] quux -- -> 0.28 - + [my-sbo] corge 2.3.0 -> 2.3.0 ok + + 3 to upgrade · 1 repo to bump · 46 current · 18 not installed diff --git a/test/test_data.lua b/test/test_data.lua index 4ed58f8..ec95fae 100644 --- a/test/test_data.lua +++ b/test/test_data.lua @@ -461,3 +461,20 @@ assert(not im.week, 'no scope line: today') assert(data.immich_parse('generated 1\nscope week\n').week, 'scope week parses') print('test_data: all assertions passed') + +-- nvtable_parse: nvtable -C -E rows. The marker column is positional, so an +-- empty installed or repo column ('--') must not shift it. +local up = data.nvtable_parse(read('test/fixtures/updates_cache')) +assert(up and #up == 6, 'updates rows, got ' .. tostring(up and #up)) +local r = up[1] +assert(r.repo == 'personal' and r.name == 'foo-bin' and r.installed == '1.14.9' + and r.shipped == '1.14.9' and r.behind and r.bump and r.upstream == '1.14.10', + 'updates row 1') +assert(up[2].behind and not up[2].bump and up[2].upstream == '2.1.284', 'updates row 2') +assert(up[3].upstream == nil and up[3].shipped == '3.16.31', 'updates row 3') +assert(up[4].shipped == nil and up[4].repo == nil, 'updates row 4: -- is nil') +assert(up[5].installed == nil and not up[5].behind, 'updates row 5: not installed') +assert(not up[6].behind and not up[6].bump, 'updates row 6: current') +assert(up.upgrade == 3 and up.bump == 1 and up.current == 46 and up.missing == 18, + 'updates summary') +assert(data.nvtable_parse('') == nil, 'updates: empty cache is nil') diff --git a/widgets/updates.lua b/widgets/updates.lua new file mode 100644 index 0000000..b50dcf3 --- /dev/null +++ b/widgets/updates.lua @@ -0,0 +1,195 @@ +-- Updates: nvtable's table of tracked packages that need action, the one the +-- login shell used to print. Wide and short, so it scrolls: the mouse wheel +-- steps through rows that do not fit. +-- +-- Everything comes from the cache bin/updates-sample.sh writes; running +-- nvtable here would cost ~0.7s per draw. + +local card = require 'lib.card' +local data = require 'lib.data' + +local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache')) + .. '/udt/updates.txt' + +-- Packages behind upstream. One is worth a glance, ten is a backlog. +local WARN_N, CRIT_N = 1, 10 + +-- First row shown, moved by the wheel. max_offset is remembered from the last +-- draw so the mouse handler can clamp without re-reading the cache. +local offset, max_offset = 0, 0 + +local M = {} + +-- nvtable's status dots, in the board's colours: 🔴 installed behind upstream, +-- 🟡 shipped .info behind, 🟢 current, ⚪ not installed (a ring). +local function dot(cr, x, y, radius, colour, hollow) + cairo_new_path(cr) + cairo_arc(cr, x, y, radius, 0, 2 * math.pi) + card.rgba(cr, colour) + if hollow then + cairo_set_line_width(cr, math.max(1, radius * 0.35)) + cairo_stroke(cr) + else + cairo_fill(cr) + end +end + +function M.draw(cr, rect, colors) + local inner = card.card(cr, rect, colors) + local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end + local LABEL_F, BIG_F, ROW_F, SUB_F = 0.30, 1.0, 0.32, 0.26 + + local up = data.nvtable_parse(data.slurp(CACHE) or '') + if not up then + card.notice(cr, inner, colors, 'UPDATES', 'no update data', + 'bin/updates-sample.sh') + return + end + + local big = up.upgrade and tostring(up.upgrade) or '--' + local sub = {} + if up.bump then sub[#sub + 1] = up.bump .. (up.bump == 1 and ' repo' or ' repos') .. ' to bump' end + if up.current then sub[#sub + 1] = up.current .. ' current' end + if up.missing and up.missing > 0 then sub[#sub + 1] = up.missing .. ' not installed' end + sub = table.concat(sub, ' · ') + + -- Header sized as if the card held ~12 rows: the table scrolls, so letting + -- the header grow with the cell's height would only squeeze the rows. + local S = clamp(math.min(inner.h * 0.94 / (1.5 + 12 * 0.66), + card.fit_unit(cr, inner.w * 0.96, { + { { card.title_str(card.ICON.updates, 'UPDATES'), card.FONT_MONO, LABEL_F }, + { big, card.FONT_HEAVY, BIG_F } }, + { { sub, card.FONT_UI, SUB_F } }, + }, 100)), 10, 72) + local label_size = S * LABEL_F + + card.font(cr, card.FONT_HEAVY, S * BIG_F, false) + card.rgba(cr, card.threshold(up.upgrade, WARN_N, CRIT_N, colors)) + card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, big) + + card.font(cr, card.FONT_MONO, label_size, false) + card.title(cr, inner.x, inner.y + label_size, card.ICON.updates, 'UPDATES', colors) + + card.font(cr, card.FONT_UI, S * SUB_F, false) + card.rgba(cr, colors.label, 0.65) + card.text(cr, inner.x, inner.y + label_size + S * SUB_F * 1.5, sub) + + local top = inner.y + S * BIG_F + S * ROW_F * 1.2 + local bottom = inner.y + inner.h + + if #up == 0 then + card.font(cr, card.FONT_UI, S * ROW_F, false) + card.rgba(cr, colors.ok) + card.text(cr, inner.x, top + S * ROW_F * 1.5, 'everything current') + return + end + + -- Column widths in characters. The font is monospaced, so padding with + -- spaces aligns columns exactly and one advance converts chars to pixels. + local wl, wn, wi, ws, wu = 2, 1, 2, 2, 0 + for _, r in ipairs(up) do + wl = math.max(wl, #(r.repo or '--')) + wn = math.max(wn, #r.name) + wi = math.max(wi, #(r.installed or '--')) + ws = math.max(ws, #(r.shipped or '--')) + wu = math.max(wu, #(r.upstream or '')) + end + -- repo, gap, name, gap, installed, ' → ', shipped, gap, two dots, gap, + -- upstream + local chars = wl + 2 + wn + 2 + wi + 3 + ws + 2 + 3 + 2 + wu + + -- Row type: the header's row ratio, shrunk until the widest row fits + -- beside the scrollbar gutter. + local gutter = 10 + card.font(cr, card.FONT_MONO, 100, false) + local per_unit = card.advance(cr, '0') / 100 + local row_size = math.max(8, math.min(S * ROW_F, (inner.w - gutter) / (chars * per_unit))) + local cw = per_unit * row_size + local step = row_size * 1.7 + local rad = row_size * 0.28 + local mid = row_size * 0.32 -- baseline to the digits' vertical centre + + -- The legend takes the bottom line; the rows scroll above it. + bottom = bottom - row_size * 2 + + local visible = math.max(1, math.floor((bottom - top) / step)) + max_offset = math.max(0, #up - visible) + offset = clamp(offset, 0, max_offset) + + local x_name = inner.x + (wl + 2) * cw + local x_inst = inner.x + inner.w - gutter - (wi + 3 + ws + 2 + 5 + wu) * cw + local x_ship = x_inst + (wi + 3) * cw + local x_mark = x_ship + (ws + 2) * cw + local x_upst = x_mark + 5 * cw + + card.font(cr, card.FONT_MONO, row_size, false) + local y = top + row_size + for i = offset + 1, math.min(#up, offset + visible) do + local r = up[i] + card.rgba(cr, colors.label, 0.65) + card.text(cr, inner.x, y, r.repo or '--') + card.rgba(cr, colors.value) + card.text(cr, x_name, y, r.name) + + -- Installed: behind upstream is the critical state, caught up is ok. + if r.installed then card.rgba(cr, r.behind and colors.critical or colors.ok) + else card.rgba(cr, colors.label, 0.5) end + card.text(cr, x_inst, y, string.format('%' .. wi .. 's', r.installed or '--')) + card.rgba(cr, colors.label, 0.65) + card.text(cr, x_inst + wi * cw, y, ' → ') + -- Shipped: a .info behind upstream is packaging work waiting, a warning. + if r.shipped then card.rgba(cr, r.bump and colors.warning or colors.value) + else card.rgba(cr, colors.label, 0.5) end + card.text(cr, x_ship, y, r.shipped or '--') + -- Installed state, then a second dot when the .info is behind too: the + -- two are independent, as nvtable shows them. + if not r.installed then dot(cr, x_mark + cw / 2, y - mid, rad, colors.label, true) + else dot(cr, x_mark + cw / 2, y - mid, rad, r.behind and colors.critical or colors.ok) end + if r.bump then dot(cr, x_mark + cw * 2.5, y - mid, rad, colors.warning) end + card.font(cr, card.FONT_MONO, row_size, false) + if r.upstream then + card.rgba(cr, colors.highlight) + card.text(cr, x_upst, y, r.upstream) + end + y = y + step + end + + -- Legend, on the card's bottom line. + local lx, ly = inner.x, inner.y + inner.h - row_size * 0.2 + card.font(cr, card.FONT_UI, row_size * 0.85, false) + for _, l in ipairs({ + { colors.critical, 'upstream newer' }, { colors.warning, 'repo behind' }, + { colors.ok, 'current' }, { colors.label, 'not installed', true }, + }) do + dot(cr, lx + rad, ly - mid, rad, l[1], l[3]) + lx = lx + rad * 2 + cw * 0.7 + card.rgba(cr, colors.label, 0.65) + card.text(cr, lx, ly, l[2]) + lx = lx + card.advance(cr, l[2]) + cw * 2.5 + end + + -- Scrollbar, only when there is something to scroll. + if max_offset > 0 then + local track_h = visible * step + local thumb_h = math.max(row_size, track_h * visible / #up) + local thumb_y = top + (track_h - thumb_h) * offset / max_offset + local bx = inner.x + inner.w - 3 + card.rounded_path(cr, bx, top, 3, track_h, 1.5) + card.rgba(cr, colors.rule, 0.35) + cairo_fill(cr) + card.rounded_path(cr, bx, thumb_y, 3, thumb_h, 1.5) + card.rgba(cr, colors.highlight, 0.8) + cairo_fill(cr) + end +end + +-- The wheel steps one row. It shows at once because the dashboard draws at +-- 0.1s while visible (see dashboard.lua). +function M.mouse(event) + if event.type ~= 'mouse_scroll' then return false end + offset = math.max(0, math.min(max_offset, + offset + (event.direction == 'up' and -1 or 1))) + return true +end + +return M -- cgit v1.2.3