Files
community-plugins/procmon/panel.luau
T
weinguyenandGitHub 2b10a25ee0 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.
2026-08-08 15:40:32 -04:00

461 lines
14 KiB
Luau

--!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