aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md23
-rwxr-xr-xbin/updates-sample.sh39
-rw-r--r--conky.conf.in2
-rw-r--r--dashboard.lua3
-rw-r--r--lib/card.lua1
-rw-r--r--lib/data.lua39
-rw-r--r--test/fixtures/updates_cache8
-rw-r--r--test/test_data.lua17
-rw-r--r--widgets/updates.lua195
9 files changed, 324 insertions, 3 deletions
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 >/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
+-- <repo> <name> <installed> -> <shipped> <marker> [~] [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