Add procmon plugin with live process panel (#301)

* Add procmon plugin with live process panel

New Noctalia plugin: bar widget shows CPU/RAM usage and process
count; floating panel has a sortable, searchable process table
with per-process kill. Background service samples ps and /proc
stats and publishes via plugin state.

* Throttle data-driven panel renders

Rebuild table at most every 500ms to stay within the panel update
CPU budget. User interactions still render immediately; only state
watches and the tick go through maybeRender().

* Update procmon deps and fix timestamp precision

Document head, grep, and cat as required commands. Correct refresh
interval default to 1000 ms. Sample refreshedAtMs in milliseconds.
This commit is contained in:
weinguyen
2026-08-08 15:40:32 -04:00
committed by GitHub
parent 936f412599
commit 2b10a25ee0
7 changed files with 963 additions and 0 deletions
+460
View File
@@ -0,0 +1,460 @@
--!nonstrict
-- Process Monitor — panel. Renders the process table + live system bars.
-- Sort/filter state lives here (a view concern); data comes from the service.
local sortKey = "cpu"
local sortDir = "desc"
local filter = ""
-- Per-column display unit: "pct" shows percent, "raw" shows the absolute
-- figure. Clicking the %CPU/%MEM header toggles it. MEM raw = RSS in MB; CPU
-- raw = cumulative CPU time (ps time=), a real number distinct from the %.
-- (ponytail: a per-interval CPU% needs two /proc/pid/stat samples and a diff,
-- too heavy for the update budget - the cumulative time is a free proxy.)
local cpuUnit = "pct"
local memUnit = "pct"
-- Cap how many rows we render so a single rebuild stays under the panel
-- update CPU budget. Rows are shown in sorted order, so the highest-sorting
-- processes are always visible. (200 rows * ~7 ui nodes blew the budget.)
local MAX_ROWS = 80
-- Cheap fingerprint of everything the table shows, so the 1s tick only rebuilds
-- the heavy UI tree when the data actually changed. Rebuilding 200 rows every
-- second exceeded the panel update CPU budget; sampling the first SIG_SAMPLE
-- rows (ps output is pid-ordered) catches CPU/mem/rss movement without the cost.
local SIG_SAMPLE = 40
local lastSig = nil
-- Throttle data-driven renders. The service can sample faster than the panel can
-- rebuild its table, and every state.set fires a watch: at 250ms that was ~16
-- full rebuilds/sec of 80 rows, which blew the panel update CPU budget and got
-- the panel disabled. User interactions (toggle/filter/sort) still render()
-- immediately; only the data watches and the tick go through maybeRender().
local lastRenderMs = 0
local MIN_RENDER_MS = 500
-- Kill column is a fixed width at the end of every row; all other columns are
-- flexGrow shares so the header and data rows always align and never overflow.
local KILL_W = 30
local function cfg(key)
return noctalia.getConfig(key)
end
local function st(key)
return noctalia.state.get(key)
end
-- Draft value while the Refresh slider is being dragged; committed to state on
-- release (audio-switcher pattern). The service watches "refreshMs" and re-arms
-- its sampler timer, which is what actually changes the fetch speed. Declared
-- after cfg/st (Luau: forward refs to a later local compile as globals -> nil).
local refreshDraft = nil
local function refreshMs()
return tonumber(st("refreshMs") or cfg("refresh_interval")) or 1000
end
local function commitRefresh()
if refreshDraft then
noctalia.state.set("refreshMs", refreshDraft)
refreshDraft = nil
end
end
local function signature()
local stats = st("stats") or {}
local cpuPct = stats.cpu and stats.cpu.usagePercent or 0
local ramPct = stats.ram and stats.ram.usagePercent or 0
local procs = st("procs") or {}
local n = #procs
local s = tostring(n) .. "|" .. filter .. "|" .. sortKey .. sortDir
.. "|" .. tostring(cpuPct) .. "|" .. tostring(ramPct)
.. "|" .. cpuUnit .. memUnit
local upto = n < SIG_SAMPLE and n or SIG_SAMPLE
for i = 1, upto do
local p = procs[i]
if p then
s = s .. "|" .. tostring(p.pid) .. ":" .. tostring(math.floor((p.cpu or 0) * 10))
.. ":" .. tostring(math.floor((p.mem or 0) * 10))
.. ":" .. tostring(math.floor((p.rssMb or 0) / 16))
end
end
return s
end
local function tr(key, subst)
return noctalia.tr(key, subst)
end
local function fmtMem(mb)
if mb == nil then
return "—"
end
if mb >= 1024 then
return string.format("%.1fG", mb / 1024)
end
return string.format("%.0fM", mb)
end
local function trunc(s, n)
if #s <= n then
return s
end
return s:sub(1, n - 1) .. "…"
end
local function killPid(pid)
local cmd = cfg("kill_command")
if cmd == "" then
cmd = "kill -TERM"
end
noctalia.runAsync(cmd .. " " .. pid)
end
-- ── stats bars ──────────────────────────────────────────────────────────────
local function statBar(label, glyph, pct, sub)
local pct0 = math.max(0, math.min(100, pct or 0))
return ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
ui.glyph({ name = glyph, size = 16 }),
ui.label({ text = label, fontSize = 11, color = "on_surface_variant" }),
ui.progress({ progress = pct0 / 100, flexGrow = 1, height = 8 }),
ui.label({ text = sub, fontSize = 11, textAlign = "end" }),
})
end
local function renderStats()
local stats = st("stats") or {}
local cpu = stats.cpu
local ram = stats.ram
local swap = stats.swap
local swapPct = 0
local swapSub = "—"
if swap and swap.totalMb and swap.totalMb > 0 then
swapPct = swap.usedMb / swap.totalMb * 100
swapSub = string.format("%.0f/%.0fM", swap.usedMb, swap.totalMb)
end
local loadSub = "—"
if stats.loadAvg then
loadSub = string.format("%.2f %.2f %.2f", stats.loadAvg[1], stats.loadAvg[2], stats.loadAvg[3])
end
-- The render path must never throw: any stats field may be nil (seed
-- stats, or a sample that skipped a field), so guard every format arg.
local cpuSub = (cpu and cpu.usagePercent ~= nil) and string.format("%.0f%%", cpu.usagePercent) or "—"
local ramSub = "—"
if ram and ram.usedMb ~= nil and ram.totalMb ~= nil then
ramSub = string.format("%.0f/%.0fM", ram.usedMb, ram.totalMb)
end
return ui.column({ gap = 4, fill = "surface_variant/0.35", radius = 10, paddingV = 8, paddingH = 10 }, {
statBar("CPU", "cpu", cpu and cpu.usagePercent, cpuSub),
statBar("RAM", "memory", ram and ram.usagePercent, ramSub),
ui.row({ gap = 12, align = "center" }, {
statBar("SWAP", "archive", swapPct, swapSub),
ui.label({ text = tr("panel.load") .. ": " .. loadSub, fontSize = 11, color = "on_surface_variant", flexGrow = 1 }),
}),
})
end
-- ── process rows ────────────────────────────────────────────────────────────
-- Column layout, shared by header and data rows so they line up.
-- { flexGrow, textAlign } for each column; cmd is the wide flexible one.
local COLS = {
{ 1.0, "end" }, -- PID
{ 1.0, "end" }, -- CPU
{ 1.0, "end" }, -- MEM
{ 1.2, "end" }, -- RSS
{ 0.7, "center" }, -- STAT
{ 5.0, "start" }, -- COMMAND
}
-- ui.select drives sorting. Order must match the settings.sort_by options.
local SORT_KEYS = { "cpu", "mem", "pid", "cmd" }
local function sortIndex(key)
for i, k in ipairs(SORT_KEYS) do
if k == key then
return i - 1
end
end
return 0
end
local function toggleCpuUnit()
cpuUnit = cpuUnit == "pct" and "raw" or "pct"
render()
end
local function toggleMemUnit()
memUnit = memUnit == "pct" and "raw" or "pct"
render()
end
local function dataRow(p)
local stat = p.stat or "?"
local statColor = stat:find("Z") and "error" or "on_surface"
local memCell = memUnit == "raw" and fmtMem(p.rssMb) or string.format("%.1f", p.mem)
local cpuCell = cpuUnit == "raw" and (p.cpuTime or "—") or string.format("%.1f", p.cpu)
local cells = {
{ tostring(p.pid) },
{ cpuCell },
{ memCell },
{ fmtMem(p.rssMb) },
{ stat, statColor },
}
local children = {}
for i, c in ipairs(cells) do
table.insert(children, ui.label({
text = c[1],
flexGrow = COLS[i][1],
textAlign = COLS[i][2],
fontSize = 11,
maxLines = 1,
color = c[2] or "on_surface",
}))
end
-- COMMAND column (flexGrow wide)
table.insert(children, ui.label({
text = trunc(p.cmd or "?", 80),
flexGrow = COLS[6][1],
textAlign = "start",
fontSize = 11,
maxLines = 1,
}))
-- Kill button (fixed trailing width)
table.insert(children, ui.button({
glyph = "square-x",
glyphSize = 14,
variant = "ghost",
controlSize = "sm",
width = KILL_W,
tooltip = tr("panel.kill_tip", { pid = p.pid }),
onClick = function()
killPid(p.pid)
end,
}))
return ui.row({ key = tostring(p.pid), gap = 6, align = "center" }, children)
end
-- Header: clickable cells use ui.row onClick (keeps layout, so the grid stays
-- aligned with the data rows). CPU/MEM headers toggle their column's display
-- unit (% vs actual). Built fresh on every render so titles track the mode.
local HEADER_LABELS = { "panel.col.pid", "panel.col.cpu", "panel.col.mem", "panel.col.rss", "panel.col.stat", "panel.col.cmd" }
local function buildHeader()
local children = {}
for i, col in ipairs(COLS) do
local label = {
text = tr(HEADER_LABELS[i]),
fontSize = 11,
maxLines = 1,
color = "on_surface_variant",
}
if i == 2 then -- %CPU header toggles percent vs raw
label.text = cpuUnit == "raw" and tr("panel.col.cpu_raw") or tr("panel.col.cpu")
children[#children + 1] = ui.row({
flexGrow = col[1],
align = "center",
justify = col[2],
onClick = toggleCpuUnit,
}, { ui.label(label) })
elseif i == 3 then -- %MEM header toggles percent vs MB
label.text = memUnit == "raw" and tr("panel.col.mem_raw") or tr("panel.col.mem")
children[#children + 1] = ui.row({
flexGrow = col[1],
align = "center",
justify = col[2],
onClick = toggleMemUnit,
}, { ui.label(label) })
else
label.flexGrow = col[1]
label.textAlign = col[2]
children[#children + 1] = ui.label(label)
end
end
-- trailing spacer matching the kill column
children[#children + 1] = ui.spacer({ width = KILL_W })
return children
end
-- ── main render ─────────────────────────────────────────────────────────────
function render()
local procs = st("procs") or {}
local f = filter:lower()
local list = {}
for _, p in ipairs(procs) do
if f ~= "" then
local cmd = (p.cmd or ""):lower()
local user = (p.user or ""):lower()
local pid = tostring(p.pid)
if not (cmd:find(f, 1, true) or user:find(f, 1, true) or pid:find(f, 1, true)) then
continue
end
end
table.insert(list, p)
end
table.sort(list, function(a, b)
local av, bv
if sortKey == "pid" then
av, bv = a.pid, b.pid
elseif sortKey == "mem" then
av, bv = a.mem, b.mem
elseif sortKey == "rss" then
av, bv = a.rssMb, b.rssMb
elseif sortKey == "cmd" then
av, bv = (a.cmd or ""), (b.cmd or "")
else
av, bv = a.cpu, b.cpu
end
if av == bv then
return a.pid < b.pid
end
if sortDir == "asc" then
return av < bv
end
return av > bv
end)
-- Bound the rendered rows so a 500-process box stays responsive.
local shown = list
if #list > MAX_ROWS then
shown = {}
for i = 1, MAX_ROWS do
shown[i] = list[i]
end
end
local body
if #shown == 0 then
body = ui.row({ align = "center", justify = "center", flexGrow = 1 }, {
ui.label({ text = tr("panel.no_processes"), color = "on_surface_variant" }),
})
else
local itemRows = {}
for _, p in ipairs(shown) do
table.insert(itemRows, dataRow(p))
end
body = ui.scroll({ flexGrow = 1, gap = 2, align = "stretch" }, itemRows)
end
local refreshedAt = (st("refreshedAtMs") or 0) / 1000
local refreshedStr = refreshedAt > 0 and os.date("%H:%M:%S", refreshedAt) or "—"
local err = st("err")
panel.render(ui.column({ padding = 12, gap = 8, flexGrow = 1, align = "stretch" }, {
ui.row({ align = "center", justify = "space_between" }, {
ui.label({ text = tr("panel.title"), fontSize = 15, fontWeight = "bold" }),
ui.label({ text = tr("panel.refreshed_at", { time = refreshedStr }), fontSize = 11, color = "on_surface_variant" }),
}),
renderStats(),
ui.row({ gap = 6, align = "center" }, {
ui.input({
key = "filter",
value = filter,
placeholder = tr("panel.search_placeholder"),
controlSize = "sm",
flexGrow = 1,
onChange = function(v)
filter = v
render()
end,
}),
ui.select({
options = {
tr("settings.sort_by.options.cpu"),
tr("settings.sort_by.options.mem"),
tr("settings.sort_by.options.pid"),
tr("settings.sort_by.options.cmd"),
},
selectedIndex = sortIndex(sortKey),
width = 110,
controlSize = "sm",
onChange = function(idx)
sortKey = SORT_KEYS[idx + 1]
render()
end,
}),
ui.button({
glyph = sortDir == "asc" and "arrow-up" or "arrow-down",
tooltip = sortDir == "asc" and tr("panel.sort_asc") or tr("panel.sort_desc"),
controlSize = "sm",
variant = "ghost",
onClick = function()
sortDir = sortDir == "asc" and "desc" or "asc"
render()
end,
}),
}),
ui.row({ gap = 6, align = "center" }, buildHeader()),
body,
ui.row({ gap = 10, align = "center" }, {
ui.label({ text = tr("panel.count", { n = #list }), fontSize = 11, color = "on_surface_variant" }),
ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
ui.label({ text = tr("panel.refresh"), fontSize = 11, color = "on_surface_variant" }),
ui.slider({
min = 500, max = 5000, step = 500, value = math.max(500, math.min(5000, refreshMs())),
flexGrow = 1, controlSize = "sm",
onChange = function(v) refreshDraft = math.floor(tonumber(v) or 0) end,
onDragEnd = commitRefresh,
}),
ui.label({ text = refreshMs() .. "ms", fontSize = 11, textAlign = "end", width = 48 }),
}),
ui.label({ text = err, fontSize = 11, color = "error" }),
}),
}))
lastSig = signature()
end
-- Data-driven render: only rebuild when the visible data actually changed AND
-- enough time has passed since the last rebuild. Defined after render/signature
-- (Luau: forward refs to a later local compile as globals -> nil).
local function maybeRender()
local now = noctalia.nowMs()
if now - lastRenderMs < MIN_RENDER_MS then
return
end
if signature() ~= lastSig then
lastRenderMs = now
render()
end
end
noctalia.state.watch("procs", maybeRender)
noctalia.state.watch("stats", maybeRender)
noctalia.state.watch("refreshedAtMs", maybeRender)
noctalia.state.watch("err", maybeRender)
noctalia.state.watch("refreshMs", function()
-- Track the fetch speed on the panel's own tick so the table stays live when
-- the user drops the interval below the 1s second-tick floor.
noctalia.setUpdateInterval(refreshMs())
render()
end)
-- Periodic re-render on the panel tick (anilist/nix-monitor pattern). Belt and
-- suspenders on top of state.watch: even if a cross-entry watch is missed in
-- some shell versions, the list refreshes on its own. Track the service refresh
-- interval so the panel keeps up when the user drops it for more real-time
-- updates. setWantsSecondTicks is safe at this API level (9router/mawaqit use it
-- at plugin_api 3).
noctalia.setUpdateInterval(refreshMs())
panel.setWantsSecondTicks(true)
function update()
maybeRender()
end
function onOpen(_context)
sortKey = cfg("sort_by") or "cpu"
sortDir = "desc"
filter = ""
render()
end