aboutsummaryrefslogtreecommitdiffstats
path: root/widgets/system.lua
blob: c1cdf67f0ac31f5db0814aabdf4d209416841714 (plain)
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
-- System: CPU load as a per-core equaliser, memory, and every temperature.
--
-- The hwmon bindings and temperature ceilings below are host-specific, which
-- is why they live here rather than in lib/data.lua: that file is the part
-- that ports to another machine, this is the part that does not.

local card = require 'lib.card'
local data = require 'lib.data'

local M = {}

-- chip, file, label, warn, crit.
--
-- Ceilings are per sensor because a single pair cannot serve both: 70C is
-- unremarkable on a CPU package and alarming on an NVMe. Starting values,
-- easy to retune once real numbers under load are known.
local TEMPS = {
  { 'k10temp',      'temp1_input', 'CPU',   75, 90 },
  { 'k10temp',      'temp3_input', 'CCD1',  75, 90 },
  { 'nvme',         'temp1_input', 'NVME',  60, 70 },
  { 'gigabyte_wmi', 'temp2_input', 'BOARD', 60, 70 },
  { 'gigabyte_wmi', 'temp3_input', 'BOARD', 60, 70 },
}

-- One counter per core, created on first draw and kept for the process's life:
-- load is a delta between samples, so the state has to outlive the frame.
local counters = {}
local total_counter = data.new_cpu_counter()

-- Sampled every ~2s whatever the draw rate; the values between samples are
-- the last ones read. 1.9 not 2: draws jitter by a few ms either way.
local gate = data.new_gate(1.9)
local load, pcts = nil, {}

-- Read once, not per frame. Neither can change without a reboot, and
-- re-reading /proc/cpuinfo every two seconds for a constant is waste: the file
-- repeats its model-name line once per thread.
--
-- `false` is the cached miss, distinct from nil meaning "not looked up yet",
-- so an absent DMI table is not re-read on every draw.
local cpu_name, board = nil, nil

local function hardware()
  if cpu_name == nil then
    cpu_name = data.cpu_model(data.slurp('/proc/cpuinfo') or '') or false
  end
  if board == nil then
    local dmi = '/sys/devices/virtual/dmi/id/'
    board = data.board_name(data.slurp(dmi .. 'board_vendor'),
                            data.slurp(dmi .. 'board_name')) or false
  end
  return cpu_name or nil, board or nil
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

  if gate() then
    local stat = data.slurp('/proc/stat') or ''
    load = total_counter:sample(data.cpu_times(stat))
    for i, c in ipairs(data.per_cpu_times(stat)) do
      counters[i] = counters[i] or data.new_cpu_counter()
      pcts[i] = counters[i]:sample(c.total, c.idle) or 0
    end
  end
  local load_txt = load and string.format('%d%%', math.floor(load + 0.5)) or '--'
  local temp_n = #TEMPS
  local cpu_txt, board_txt = hardware()

  -- Fluid type. One base size S drives the label, headline, RAM and every
  -- temperature row; the equaliser then absorbs the vertical room left over,
  -- so the card fills its cell and S shrinks only where a row would overrun
  -- the width. card.fit_unit supplies the width side of that.
  local LABEL_F, BIG_F, ROW_F = 0.30, 1.0, 0.32
  local groups = {
    { { card.title_str(card.ICON.system, 'CPU'), card.FONT_MONO, LABEL_F },
      { load_txt, card.FONT_HEAVY, BIG_F } },
    { { cpu_txt or '', card.FONT_MONO, ROW_F } },
    { { board_txt or '', card.FONT_MONO, ROW_F } },
    { { 'RAM', card.FONT_MONO, ROW_F }, { '999.9G / 999.9G', card.FONT_MONO, ROW_F } },
  }
  for _, t in ipairs(TEMPS) do
    groups[#groups + 1] = { { t[3], card.FONT_MONO, ROW_F },
                            { '888\u{00B0}', card.FONT_MONO, ROW_F } }
  end
  -- Height the non-equaliser content needs, in units of S, plus a reserve for
  -- the equaliser band. The smaller of that budget and the width fit is S.
  -- The two identification rows come out of the equaliser's budget: it is the
  -- card's designated slack absorber, so it is what gives up the height.
  local id_rows = (cpu_txt and 1 or 0) + (board_txt and 1 or 0)
  local fixed_units = 1.00 + 1.13 + temp_n * ROW_F * 1.8 + 0.10
    + id_rows * ROW_F * 1.5
  local S = clamp(math.min(inner.h * 0.96 / (fixed_units + 1.7),
    card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
  local label_size = S * LABEL_F
  local big_size   = S * BIG_F
  local row_size   = S * ROW_F

  local y = inner.y

  -- Aggregate load, the headline number. It takes the header line, right
  -- aligned like the big value on every other card, with the label top-left.
  card.font(cr, card.FONT_HEAVY, big_size, false)
  card.rgba(cr, load and card.threshold(load, 25, 75, colors) or colors.label)
  card.text_right(cr, inner.x + inner.w, y + big_size, load_txt)

  card.font(cr, card.FONT_MONO, label_size, false)
  card.title(cr, inner.x, y + label_size, card.ICON.system, 'CPU', colors)

  -- The hardware this card is about, under the header. Truncated by character,
  -- never by byte: a model string can carry a multi-byte character and half of
  -- one draws as a replacement box.
  -- Two accents rather than one label colour: the rows name two different
  -- pieces of hardware, and giving each its own hue separates them at a glance
  -- without a prefix like 'CPU:' eating the width. `heading` and `highlight`
  -- are the two roles that carry no state meaning, so they can stand for an
  -- identity; ok/warning/critical could not, since those say how something is
  -- doing and these strings never change.
  local id_y = y + big_size + row_size * 0.2
  if cpu_txt or board_txt then
    card.font(cr, card.FONT_MONO, row_size, false)
    local id_limit = math.max(8, math.floor(inner.w / (row_size * 0.55)))
    if cpu_txt then
      card.rgba(cr, colors.heading)
      card.text(cr, inner.x, id_y, card.truncate(cpu_txt, id_limit))
      id_y = id_y + row_size * 1.5
    end
    if board_txt then
      card.rgba(cr, colors.highlight)
      card.text(cr, inner.x, id_y, card.truncate(board_txt, id_limit))
      id_y = id_y + row_size * 1.5
    end
  end

  -- The equaliser: one bar per core, rising from a common baseline. It takes
  -- the room left between the header and the RAM/temperature block, so it is
  -- the card's slack absorber.
  -- Below the identification rows when there are any, otherwise where it was.
  local eq_top = (cpu_txt or board_txt) and (id_y + S * 0.10) or (y + big_size + S * 0.30)
  local bar_h  = S * 0.18
  local step   = row_size * 1.8
  -- The floor was a flat 18px. With the identification rows above it the
  -- equaliser can genuinely run out of room, and a fixed floor means it keeps
  -- height it does not have and the temperature rows draw over it. Zero is a
  -- legitimate outcome: the guard below skips the band when it has no height.
  local eh = clamp((inner.y + inner.h) - eq_top - (S * 1.13 + temp_n * step),
    0, math.huge)
  local n = #pcts
  if n > 0 and eh >= 8 then
    local gap = math.max(2, inner.w * 0.006)
    local bw = (inner.w - gap * (n - 1)) / n
    -- Below about 3px a row of sixteen bars is an illegible smear; drop to the
    -- aggregate bar instead of drawing one.
    if bw >= 3 then
      for i, pct in ipairs(pcts) do
        card.vbar(cr, inner.x + (i - 1) * (bw + gap), eq_top + eh, bw, eh,
          pct / 100, card.threshold(pct, 25, 75, colors), colors)
      end
    else
      card.bar(cr, inner.x, eq_top + eh - bar_h, inner.w, bar_h, (load or 0) / 100,
        card.threshold(load or 0, 25, 75, colors), colors)
    end
  end
  local ey = eq_top + eh + S * 0.30

  -- Memory. MemAvailable, not free: it already excludes reclaimable cache, so
  -- it is the figure a person recognises as "used".
  local mem = data.mem_info(data.slurp('/proc/meminfo') or '')
  if mem then
    local frac = mem.used / mem.total
    card.font(cr, card.FONT_MONO, row_size, false)
    card.rgba(cr, colors.label)
    card.text(cr, inner.x, ey, 'RAM')
    card.rgba(cr, colors.value)
    card.text_right(cr, inner.x + inner.w, ey,
      string.format('%s / %s', card.human(mem.used * 1024), card.human(mem.total * 1024)))
    ey = ey + S * 0.30
    card.bar(cr, inner.x, ey, inner.w, bar_h, frac,
      card.threshold(frac * 100, 25, 75, colors), colors)
    ey = ey + bar_h + S * 0.35
  end

  -- Temperatures, each against its own ceiling.
  card.font(cr, card.FONT_MONO, row_size, false)
  for _, t in ipairs(TEMPS) do
    if ey + step > inner.y + inner.h then break end   -- out of room: stop
    local v = data.sensor(t[1], t[2])
    card.rgba(cr, colors.label)
    card.text(cr, inner.x, ey, t[3])
    card.rgba(cr, v and card.threshold(v, t[4], t[5], colors) or colors.label)
    card.text_right(cr, inner.x + inner.w, ey,
      v and string.format('%d\u{00B0}', v) or '--')
    ey = ey + step
  end
end

return M