Update(procmon-plugin): Add keyboard navigation and reduce CPU sampling (#321)

* 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.

* Add keyboard navigation and reduce CPU sampling

- Panel: arrow keys move cursor, Ctrl+D kills selected, Ctrl+F focuses
  filter
- Service: sample /proc stats every 1s, ps every 2s; 2s render floor
- Widget: handle vertical bars, truncate text, use valid theme color
- Bump version to 0.4.0, plugin_api 13
This commit is contained in:
weinguyen
2026-08-09 13:55:01 -04:00
committed by GitHub
parent 82f1cffd55
commit 44e32f58bc
6 changed files with 274 additions and 111 deletions
+36 -26
View File
@@ -36,13 +36,14 @@ end
-- `args` is placed last so the fixed fields are positional and everything
-- after them is the full command line (which may contain spaces). The shell
-- pre-sorts by CPU and truncates to the top 120 rows so the Luau parse stays
-- pre-sorts by CPU and truncates to the top 80 rows so the Luau parse stays
-- tiny: parsing every process blew the async callback's CPU budget even with a
-- single-pass gmatch. btm shows a screenful anyway, so top-120 is plenty.
-- single-pass gmatch. The panel caps at 80 rows (MAX_ROWS), so parsing more is
-- wasted work -- top-80 is exactly what gets displayed.
-- The trailing ##STAT/##MEM sections carry real total CPU/RAM from /proc so the
-- bars read the machine's actual usage -- ps %cpu is a per-process lifetime
-- average, so summing the top rows and capping at 100 always pegged the bar.
local PS_CMD = "ps -eo pid=,user=,%cpu=,%mem=,rss=,stat=,time=,args= --sort=-%cpu | head -n 120"
local PS_CMD = "ps -eo pid=,user=,%cpu=,%mem=,rss=,stat=,time=,args= --sort=-%cpu | head -n 80"
-- Real total CPU/RAM come from /proc in a SEPARATE, tiny command so the async
-- callback that parses it stays far under the CPU budget. Folding the markers
@@ -170,35 +171,22 @@ local function sampleRam(raw)
return math.floor(usedKb / totalKb * 100 + 0.5), math.floor(usedKb / 1024), math.floor(totalKb / 1024)
end
local sampling = false
-- inFlight counts outstanding async callbacks. sample() only starts when it is
-- 0, so a slow `ps` can never overlap the next tick's subprocesses -- overlapping
-- spawns were a big part of the CPU-budget blowout.
local inFlight = 0
local tick = 0
local function sample()
if sampling then
if inFlight > 0 then
return -- one in-flight sample at a time
end
sampling = true
noctalia.runAsync(PS_CMD, function(res)
local ok, perr = pcall(function()
sampling = false
if res == nil or res.timedOut or res.exitCode ~= 0 then
local detail = res and (res.stderr or res.stdout or "") or "no result"
setState("err", detail)
return
end
local procs = parse(res.stdout)
setState("procs", procs)
setState("refreshedAtMs", os.time() * 1000)
end)
if not ok then
sampling = false
log("ps callback error: " .. tostring(perr))
end
end, PS_TIMEOUT_MS)
tick += 1
-- Cheap /proc stats every tick: the bars stay real-time.
inFlight += 1
noctalia.runAsync(STATS_CMD, function(res)
local ok, perr = pcall(function()
sampling = false
if res == nil or res.timedOut or res.exitCode ~= 0 then
return
end
@@ -215,10 +203,32 @@ local function sample()
setState("err", "")
end)
if not ok then
sampling = false
log("stats callback error: " .. tostring(perr))
end
inFlight -= 1
end, PS_TIMEOUT_MS)
-- The `ps` subprocess is the biggest CPU cost; the process table changes
-- slowly, so sample it every 2nd tick while stats stay fresh every tick.
if tick % 2 == 1 then
inFlight += 1
noctalia.runAsync(PS_CMD, function(res)
local ok, perr = pcall(function()
if res == nil or res.timedOut or res.exitCode ~= 0 then
local detail = res and (res.stderr or res.stdout or "") or "no result"
setState("err", detail)
return
end
local procs = parse(res.stdout)
setState("procs", procs)
setState("refreshedAtMs", os.time() * 1000)
end)
if not ok then
log("ps callback error: " .. tostring(perr))
end
inFlight -= 1
end, PS_TIMEOUT_MS)
end
end
-- Seed placeholders so subscribers have something before the first sample.