diff options
| -rw-r--r-- | lib/data.lua | 25 | ||||
| -rw-r--r-- | test/fixtures/ip_addr | 3 | ||||
| -rw-r--r-- | test/test_data.lua | 13 | ||||
| -rw-r--r-- | widgets/network.lua | 173 |
4 files changed, 214 insertions, 0 deletions
diff --git a/lib/data.lua b/lib/data.lua index 98c6898..4803f5b 100644 --- a/lib/data.lua +++ b/lib/data.lua @@ -378,4 +378,29 @@ function M.board_name(vendor, name) return name or vendor end +-- The IPv4 address from `ip -4 addr show <iface>` output. +-- +-- Split from the command that produces it so it can be tested against a +-- fixture: every other parser in this file follows the same split, and an +-- address is exactly the kind of value that must not be hardcoded in a test. +function M.iface_addr_parse(text) + if type(text) ~= 'string' then return nil end + return text:match('inet%s+(%d+%.%d+%.%d+%.%d+)') +end + +-- The interface's IPv4 address, or nil when it has none. +-- +-- This shells out, which the draw hook otherwise never does, so the caller +-- memoizes it: an address does not change without an event this dashboard +-- does not watch. Retried while nil, because a bridge may not be up when +-- conky starts. +function M.iface_addr(iface) + if type(iface) ~= 'string' then return nil end + local p = io.popen('ip -4 addr show ' .. ("%q"):format(iface) .. ' 2>/dev/null') + if not p then return nil end + local out = p:read('*a') + p:close() + return M.iface_addr_parse(out) +end + return M diff --git a/test/fixtures/ip_addr b/test/fixtures/ip_addr new file mode 100644 index 0000000..8d3f212 --- /dev/null +++ b/test/fixtures/ip_addr @@ -0,0 +1,3 @@ +4: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000 + inet 192.0.2.15/24 brd 192.0.2.255 scope global br0 + valid_lft forever preferred_lft forever diff --git a/test/test_data.lua b/test/test_data.lua index 74167bf..34021c5 100644 --- a/test/test_data.lua +++ b/test/test_data.lua @@ -309,4 +309,17 @@ assert(data.cpu_model('model name\t: AMD Ryzen 9 7950X3D 16-Core Processor\n') == 'Ryzen 9 7950X3D', 'x3d, got ' .. tostring(data.cpu_model('model name\t: AMD Ryzen 9 7950X3D 16-Core Processor\n'))) +-- === Interface address ==================================================== +-- The fixture uses TEST-NET-1 (192.0.2.0/24, RFC 5737). This repository is +-- public: no real LAN address belongs in it, and no test may assert one. +local addr = data.iface_addr_parse(read('test/fixtures/ip_addr')) +assert(addr == '192.0.2.15', 'lan address, got ' .. tostring(addr)) + +-- A down interface has no inet line. nil, so the card shows '--' rather than +-- a stale or invented address. +assert(data.iface_addr_parse('5: br0: <BROADCAST,MULTICAST> mtu 1500 state DOWN\n') == nil, + 'a down interface gives nil') +assert(data.iface_addr_parse('') == nil, 'empty gives nil') +assert(data.iface_addr_parse(nil) == nil, 'nil gives nil') + print('test_data: all assertions passed') diff --git a/widgets/network.lua b/widgets/network.lua new file mode 100644 index 0000000..e0ff4cb --- /dev/null +++ b/widgets/network.lua @@ -0,0 +1,173 @@ +-- Network: throughput as two lines on one chart, with the LAN and public +-- addresses. +-- +-- The rates come from the interface's own byte counters, read every draw: two +-- file reads, no subprocess. The public address comes from a cache file, +-- because a network call in the draw hook would freeze the dashboard when it +-- hung. + +local card = require 'lib.card' +local data = require 'lib.data' + +local M = {} + +-- The interface to graph. br0 is this host's bridge, matching the old text +-- config. +-- +-- Note what this means: a bridge carries VM-to-host traffic that never reaches +-- the router, so a local copy spikes the graph. That was true of the old +-- config too and is accepted; this constant is the one edit that changes it. +local IFACE = 'br0' + +local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache')) + .. '/udt/pubip.txt' + +-- A public address older than this is not shown. Eight sampling intervals: by +-- then the sampler has failed repeatedly, and an address that may no longer be +-- yours displayed with confidence is worse than '--'. +local STALE_AFTER = 4 * 3600 + +local STAT = '/sys/class/net/' .. IFACE .. '/statistics/' + +-- Module state: the history outlives the frame, which is the whole point. +-- It does not outlive a conky restart, so the chart starts empty and fills +-- left to right. +local rx_counter, tx_counter +local rx_hist, tx_hist = {}, {} +local hist_len = 0 +local lan_addr = nil + +-- Append to a fixed-length history, dropping the oldest. A plain array with +-- table.remove(1) rather than a circular buffer with an index: the lengths +-- here are a few hundred at most and the draw is every two seconds, so the +-- O(n) shift is free and the array is already in draw order. +local function push(hist, v, len) + hist[#hist + 1] = v + while #hist > len do table.remove(hist, 1) end +end + +local function read_counter(file) + local s = data.slurp(STAT .. file) + if not s then return nil end + return tonumber(s:match('^%s*(%d+)')) +end + +-- Bytes per second to a short string. Deliberately not card.human: a rate +-- wants a '/s' and one decimal at most, and reusing card.human would put a +-- suffix meant for capacity onto a speed. +local function rate_str(bps) + if not bps then return '--' end + local units = { 'B', 'K', 'M', 'G' } + local n, i = bps, 1 + while n >= 1024 and i < #units do n = n / 1024; i = i + 1 end + if i == 1 then return string.format('%d%s/s', math.floor(n), units[i]) end + if n >= 100 then return string.format('%.0f%s/s', n, units[i]) end + return string.format('%.1f%s/s', n, units[i]) +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 interval = (conky_info and conky_info.update_interval) or 2 + rx_counter = rx_counter or data.new_rate_counter(interval) + tx_counter = tx_counter or data.new_rate_counter(interval) + + local down = rx_counter:sample(read_counter('rx_bytes')) + local up = tx_counter:sample(read_counter('tx_bytes')) + + -- Fluid type, as every other card does: one base size S drives everything, + -- taken as the smaller of a height budget and a measured width fit. + local LABEL_F, BIG_F, ROW_F = 0.30, 0.62, 0.30 + local groups = { + { { 'NET', card.FONT_MONO, LABEL_F }, { '888.8M/s', card.FONT_HEAVY, BIG_F } }, + { { '\u{F0318} 192.000.000.000', card.FONT_MONO, ROW_F } }, + { { '\u{F01DA} 888.8M/s', card.FONT_MONO, ROW_F }, + { '\u{F0552} 888.8M/s', card.FONT_MONO, ROW_F } }, + } + local fixed_units = 1.0 + ROW_F * 2.2 * 3 + local S = clamp(math.min(inner.h * 0.96 / (fixed_units + 1.2), + card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72) + local label_size = S * LABEL_F + local row_size = S * ROW_F + + -- Header: the label left, the download rate as the big value at the right. + -- Download is the headline because it is the number that moves. + card.font(cr, card.FONT_HEAVY, S * BIG_F, false) + card.rgba(cr, colors.body) + card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, rate_str(down)) + + card.font(cr, card.FONT_MONO, label_size, false) + card.rgba(cr, colors.label) + card.text(cr, inner.x, inner.y + label_size, 'NET') + + local ey = inner.y + S * BIG_F + row_size * 1.4 + local step = row_size * 2.2 + + -- The addresses. LAN is memoized on first success and retried while nil: a + -- bridge may not be up when conky starts. + lan_addr = lan_addr or data.iface_addr(IFACE) + card.font(cr, card.FONT_MONO, row_size, false) + card.rgba(cr, colors.label) + card.text(cr, inner.x, ey, '\u{F0318}') -- LAN + card.rgba(cr, colors.value) + card.text_right(cr, inner.x + inner.w, ey, lan_addr or '--') + ey = ey + step + + local pub = data.kv_parse(data.slurp(CACHE) or '') + local fetched = tonumber(pub.fetched) + local pub_txt = '--' + if pub.ip and fetched and (os.time() - fetched) < STALE_AFTER then + pub_txt = pub.ip + end + card.rgba(cr, colors.label) + card.text(cr, inner.x, ey, '\u{F059F}') -- globe + card.rgba(cr, pub_txt == '--' and colors.label or colors.value) + card.text_right(cr, inner.x + inner.w, ey, pub_txt) + ey = ey + step * 1.1 + + -- The chart takes the room left between the addresses and the rate row. + local rate_row_h = row_size * 2.4 + local chart_top = ey + local chart_h = (inner.y + inner.h) - chart_top - rate_row_h + if chart_h > 12 then + -- One sample per pixel column, so the chart never interpolates. The + -- buffer is resized when the card is, keeping what it can: a moved card + -- should not clear the history. + local want = math.max(8, math.floor(inner.w)) + if want ~= hist_len then + hist_len = want + while #rx_hist > hist_len do table.remove(rx_hist, 1) end + while #tx_hist > hist_len do table.remove(tx_hist, 1) end + end + if down then push(rx_hist, down, hist_len) end + if up then push(tx_hist, up, hist_len) end + + -- One ceiling for both series. Scaled independently they would lie about + -- their relative size, which is the entire reason to draw them together. + local peak = 0 + for _, v in ipairs(rx_hist) do if v > peak then peak = v end end + for _, v in ipairs(tx_hist) do if v > peak then peak = v end end + + card.plot(cr, inner.x, chart_top, inner.w, chart_h, { + { values = rx_hist, colour = colors.ok }, + { values = tx_hist, colour = colors.highlight }, + }, peak, colors) + + -- The peak, so a full-height line means something in absolute terms. + card.font(cr, card.FONT_MONO, row_size * 0.85, false) + card.rgba(cr, colors.label) + card.text(cr, inner.x, chart_top + row_size * 0.85, rate_str(peak)) + end + + -- The live rates, each in its series colour so the line and the number are + -- unmistakably the same thing. + local ry = inner.y + inner.h - row_size * 0.4 + card.font(cr, card.FONT_MONO, row_size, false) + card.rgba(cr, colors.ok) + card.text(cr, inner.x, ry, '\u{F01DA} ' .. rate_str(down)) + card.rgba(cr, colors.highlight) + card.text_right(cr, inner.x + inner.w, ry, '\u{F0552} ' .. rate_str(up)) +end + +return M |
