* 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
262 lines
8.6 KiB
Luau
262 lines
8.6 KiB
Luau
--!nonstrict
|
|
-- Process Monitor — background sampler (service).
|
|
--
|
|
-- Runs `ps` every refresh_interval ms, parses the rows, and publishes the
|
|
-- process list plus a lightweight CPU/RAM summary through the plugin state
|
|
-- channel. The bar widget and panel are pure subscribers.
|
|
--
|
|
-- Structure matches the proven audio-switcher/drive-health service pattern:
|
|
-- noctalia.setUpdateInterval(...) + function update() + a guarded runAsync.
|
|
--
|
|
-- Root-cause hardening (added 0.1.5):
|
|
-- * Every step logs via noctalia.log so runtime failures are visible in the
|
|
-- shell's plugin log instead of the silent "service stopped responding".
|
|
-- * The runAsync callback body runs under pcall: an internal error can no
|
|
-- longer take the service VM down and trip the watchdog; it logs instead.
|
|
-- * refreshedAtMs uses os.time()*1000 (milliseconds) so the sampler has no
|
|
-- dependency on noctalia.nowMs().
|
|
-- * stats is derived from the ps output itself (summed %cpu/%mem/rss), so the
|
|
-- bars work without noctalia.systemStats().
|
|
|
|
local function cfg(key)
|
|
return noctalia.getConfig(key)
|
|
end
|
|
|
|
local function setState(key, val)
|
|
noctalia.state.set(key, val)
|
|
end
|
|
|
|
local function st(key)
|
|
return noctalia.state.get(key)
|
|
end
|
|
|
|
local function log(msg)
|
|
noctalia.log("[procmon/service] " .. msg)
|
|
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 80 rows so the Luau parse stays
|
|
-- tiny: parsing every process blew the async callback's CPU budget even with a
|
|
-- 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 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
|
|
-- into the ps command made one callback scan the whole ~15KB buffer two extra
|
|
-- times (string.match over the full output) and tipped it over the razor-thin
|
|
-- budget. Each callback now parses only its own small output.
|
|
local STATS_CMD = "grep '^cpu ' /proc/stat; echo '##MEM'; grep -E 'MemTotal:|MemAvailable:|SwapTotal:|SwapFree:' /proc/meminfo; echo '##LOAD'; cat /proc/loadavg"
|
|
|
|
-- Single-pass row pattern. ps -eo with trailing `=` emits no header. Six
|
|
-- space-delimited fields then `([^\n]*)` grabs the rest of the line (args, which
|
|
-- may contain spaces). Note: `.*` would cross newlines in Lua (dot matches `\n`),
|
|
-- collapsing every remaining row into the first one -- so args must be `[^\n]*`.
|
|
-- One gmatch pass, no per-token tables, no trim gsubs. ppid/etime are dropped
|
|
-- (not shown in the panel) to keep captures to a minimum for the CPU budget.
|
|
local ROW = "(%S+)%s+(%S+)%s+(%S+)%s+(%S+)%s+(%S+)%s+(%S+)%s+(%S+)%s+([^\n]*)"
|
|
|
|
-- Upper bound on how long `ps` may take before we give up on a sample.
|
|
local PS_TIMEOUT_MS = 5000
|
|
|
|
local function parse(raw)
|
|
local list, n = {}, 0
|
|
for pidS, user, cpuS, memS, rssS, stat, timeS, args in raw:gmatch(ROW) do
|
|
local pid = tonumber(pidS)
|
|
if pid ~= nil then
|
|
n += 1
|
|
list[n] = {
|
|
pid = pid,
|
|
user = user,
|
|
cpu = tonumber(cpuS) or 0,
|
|
mem = tonumber(memS) or 0,
|
|
rssMb = (tonumber(rssS) or 0) / 1024,
|
|
stat = stat,
|
|
cpuTime = timeS or "0:00",
|
|
cmd = args,
|
|
}
|
|
end
|
|
end
|
|
return list
|
|
end
|
|
|
|
-- Real total CPU% from /proc/stat deltas between consecutive samples. ps %cpu is
|
|
-- a lifetime average, so we diff the kernel's cumulative jiffies instead.
|
|
local prevCpu = nil -- { busy, total }
|
|
|
|
local function sampleCpu(raw)
|
|
local line = raw:match("^cpu%s+([^\n]+)")
|
|
if not line then
|
|
return nil
|
|
end
|
|
local t = {}
|
|
for n in line:gmatch("%d+") do
|
|
t[#t + 1] = tonumber(n)
|
|
end
|
|
if #t < 4 then
|
|
return nil
|
|
end
|
|
-- cpu user nice system idle iowait irq softirq steal ... ; idle=iowait=0 load.
|
|
local total, busy = 0, 0
|
|
for i = 1, 8 do
|
|
local v = t[i] or 0
|
|
total += v
|
|
if i ~= 4 and i ~= 5 then
|
|
busy += v
|
|
end
|
|
end
|
|
if not prevCpu then
|
|
prevCpu = { busy = busy, total = total }
|
|
return nil -- need one more sample to diff
|
|
end
|
|
local db = busy - prevCpu.busy
|
|
local dt = total - prevCpu.total
|
|
prevCpu = { busy = busy, total = total }
|
|
if dt <= 0 then
|
|
return 0
|
|
end
|
|
local pct = db / dt * 100
|
|
if pct < 0 then
|
|
pct = 0
|
|
elseif pct > 100 then
|
|
pct = 100
|
|
end
|
|
return math.floor(pct + 0.5)
|
|
end
|
|
|
|
local function sampleSwap(raw)
|
|
local sec = raw:match("##MEM\n(.+)") or ""
|
|
local totalKb = tonumber(sec:match("SwapTotal:%s*(%d+)") or "")
|
|
if not totalKb or totalKb <= 0 then
|
|
return nil
|
|
end
|
|
local freeKb = tonumber(sec:match("SwapFree:%s*(%d+)") or "")
|
|
local usedKb = totalKb - (freeKb or 0)
|
|
if usedKb < 0 then
|
|
usedKb = 0
|
|
end
|
|
return math.floor(usedKb / totalKb * 100 + 0.5), math.floor(usedKb / 1024), math.floor(totalKb / 1024)
|
|
end
|
|
|
|
-- Load average (1/5/15 min) from /proc/loadavg.
|
|
local function sampleLoad(raw)
|
|
local line = raw:match("##LOAD\n([^\n]+)")
|
|
if not line then
|
|
return nil
|
|
end
|
|
local a = {}
|
|
for n in line:gmatch("%d+%.%d+") do
|
|
a[#a + 1] = tonumber(n)
|
|
end
|
|
if #a >= 3 then
|
|
return { a[1], a[2], a[3] }
|
|
end
|
|
return nil
|
|
end
|
|
local function sampleRam(raw)
|
|
local sec = raw:match("##MEM\n(.+)") or ""
|
|
local totalKb = tonumber(sec:match("MemTotal:%s*(%d+)") or "")
|
|
local availKb = tonumber(sec:match("MemAvailable:%s*(%d+)") or "")
|
|
if not totalKb or not availKb or totalKb <= 0 then
|
|
return nil
|
|
end
|
|
local usedKb = totalKb - availKb
|
|
if usedKb < 0 then
|
|
usedKb = 0
|
|
end
|
|
return math.floor(usedKb / totalKb * 100 + 0.5), math.floor(usedKb / 1024), math.floor(totalKb / 1024)
|
|
end
|
|
|
|
-- 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 inFlight > 0 then
|
|
return -- one in-flight sample at a time
|
|
end
|
|
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()
|
|
if res == nil or res.timedOut or res.exitCode ~= 0 then
|
|
return
|
|
end
|
|
local cpuPct = sampleCpu(res.stdout)
|
|
local ramPct, usedMb, totalMb = sampleRam(res.stdout)
|
|
local swapPct, swapUsedMb, swapTotalMb = sampleSwap(res.stdout)
|
|
local loadAvg = sampleLoad(res.stdout)
|
|
setState("stats", {
|
|
cpu = { usagePercent = cpuPct or 0 },
|
|
ram = { usagePercent = ramPct or 0, usedMb = usedMb or 0, totalMb = totalMb or 0 },
|
|
swap = { usagePercent = swapPct or 0, usedMb = swapUsedMb or 0, totalMb = swapTotalMb or 0 },
|
|
loadAvg = loadAvg or { 0, 0, 0 },
|
|
})
|
|
setState("err", "")
|
|
end)
|
|
if not ok then
|
|
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.
|
|
setState("procs", {})
|
|
setState("stats", { cpu = { usagePercent = 0 }, ram = { usagePercent = 0, usedMb = 0, totalMb = 0 } })
|
|
setState("refreshedAtMs", 0)
|
|
setState("err", "")
|
|
|
|
local function refreshInterval()
|
|
return tonumber(st("refreshMs") or cfg("refresh_interval")) or 1000
|
|
end
|
|
|
|
-- The panel's Refresh slider publishes its value here; re-arming the sampler
|
|
-- timer is how the fetch speed actually changes. Seeded from config so a
|
|
-- reload falls back to the user's saved setting.
|
|
setState("refreshMs", cfg("refresh_interval") or 1000)
|
|
noctalia.state.watch("refreshMs", function(v)
|
|
local n = tonumber(v)
|
|
if n and n > 0 then
|
|
noctalia.setUpdateInterval(n)
|
|
log("interval=" .. n)
|
|
end
|
|
end)
|
|
|
|
noctalia.setUpdateInterval(refreshInterval())
|
|
log("loaded, interval=" .. refreshInterval())
|
|
|
|
function update()
|
|
sample()
|
|
end
|