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
+60
View File
@@ -0,0 +1,60 @@
# Process Monitor
A bottom-style process monitor for Noctalia: live CPU/RAM/swap bars and a
sortable, searchable process table with a per-process kill action, all in a
panel. Great for spotting a runaway process or a zombie and killing it without
opening a terminal.
## Plugin
| Field | Value |
| ------- | -------------------------------------------------------- |
| ID | `weinguyen/procmon` |
| Entries | Bar widget: `widget`; panel: `panel`; service: `service` |
## Requirements
Install `ps`, `kill`, `head`, `grep` and `cat` on `PATH` (all are present on
virtually every Linux distribution).
## Usage
Add the **Process Monitor** widget to a bar. It shows the current CPU and RAM
usage (plus the process count). Click the widget to toggle the process panel:
```sh
noctalia msg panel-toggle weinguyen/procmon:panel
```
The panel shows CPU, RAM and swap bars with the 1/5/15-minute load averages,
then a process table. Sort by the column dropdown (PID, CPU%, MEM%, RSS,
COMMAND) and flip asc/desc with the arrow button. Type in the filter box to
match a process by name, user or PID. Click the ✕ button on a row to run the
configured kill command against that PID (default `kill -TERM`). Zombie
processes are tinted with the error color so they stand out.
The table refreshes on the interval set in the `refresh_interval` setting. The
panel re-renders automatically as new samples arrive, so the view stays live
while it is open.
## Settings
| Setting | Type | Default | Description |
| ------------------ | -------- | ------------ | ------------------------------------------------------------------------------------------ |
| `refresh_interval` | `int` | `1000` | Process resample interval in milliseconds. (default 1000) |
| `sort_by` | `select` | `cpu` | Initial sort column when the panel opens (`cpu`, `mem`, `pid`, `cmd`). |
| `kill_command` | `string` | `kill -TERM` | Command run against a selected PID; the PID is appended. Empty falls back to `kill -TERM`. |
| `show_count` | `bool` | `true` | Bar widget also shows the total process count. |
## Notes
- **Spawns processes.** A background service runs `ps` on every refresh
interval and `runAsync` runs the configured `kill_command` when a row's ✕ is
clicked. There is no confirmation dialog, so check the PID before clicking.
- The bar widget only renders data the service publishes; it never runs
commands. The panel runs the configured `kill_command` when a row's ✕ is
clicked.
- Requires `plugin_api = 12`. CPU%, RAM%, swap and the 1/5/15-minute load
averages are sampled from `/proc` (`/proc/stat`, `/proc/meminfo`,
`/proc/loadavg`) by the service, so they work with no separate system-monitor
dependency.
+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
+77
View File
@@ -0,0 +1,77 @@
id = "weinguyen/procmon"
name = "Process Monitor"
version = "0.3.0"
plugin_api = 12
author = "weinguyen"
license = "MIT"
icon = "cpu"
description = "A bottom-style process monitor: live CPU/RAM/swap bars and a sortable, searchable process table with per-process kill."
tags = ["bar", "panel", "service", "system", "utility"]
dependencies = ["ps", "kill", "head", "grep", "cat"]
[[setting]]
key = "refresh_interval"
type = "int"
label_key = "settings.refresh_interval.label"
description_key = "settings.refresh_interval.description"
default = 1000
min = 250
max = 30000
[[setting]]
key = "sort_by"
type = "select"
label_key = "settings.sort_by.label"
description_key = "settings.sort_by.description"
default = "cpu"
options = [
{ value = "cpu", label_key = "settings.sort_by.options.cpu" },
{ value = "mem", label_key = "settings.sort_by.options.mem" },
{ value = "pid", label_key = "settings.sort_by.options.pid" },
{ value = "cmd", label_key = "settings.sort_by.options.cmd" },
]
[[setting]]
key = "kill_command"
type = "string"
label_key = "settings.kill_command.label"
description_key = "settings.kill_command.description"
default = "kill -TERM"
[[widget]]
id = "widget"
entry = "widget.luau"
[[widget.setting]]
key = "glyph"
type = "glyph"
label_key = "settings.glyph.label"
description_key = "settings.glyph.description"
default = "cpu"
[[widget.setting]]
key = "show_count"
type = "bool"
label_key = "settings.show_count.label"
description_key = "settings.show_count.description"
default = true
[[widget.setting]]
key = "icon_only"
type = "bool"
label_key = "settings.icon_only.label"
description_key = "settings.icon_only.description"
default = false
[[panel]]
id = "panel"
entry = "panel.luau"
width = 620
height = 660
placement = "floating"
position = "center"
open_near_click = true
[[service]]
id = "service"
entry = "service.luau"
+251
View File
@@ -0,0 +1,251 @@
--!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 120 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.
-- 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"
-- 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
local sampling = false
local function sample()
if sampling 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)
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
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
sampling = false
log("stats callback error: " .. tostring(perr))
end
end, PS_TIMEOUT_MS)
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+59
View File
@@ -0,0 +1,59 @@
{
"settings": {
"refresh_interval": {
"label": "Refresh interval (ms)",
"description": "How often the process list is resampled."
},
"sort_by": {
"label": "Default sort column",
"description": "Column the process table is sorted by when the panel opens.",
"options": {
"cpu": "CPU",
"mem": "Memory",
"pid": "PID",
"cmd": "Command"
}
},
"kill_command": {
"label": "Kill command",
"description": "Command run against a selected PID (the PID is appended). Empty uses kill -TERM."
},
"show_count": {
"label": "Show process count",
"description": "Append the number of processes to the bar widget text."
},
"glyph": {
"label": "Widget icon",
"description": "Glyph shown in the bar widget."
},
"icon_only": {
"label": "Icon only",
"description": "Show only a CPU icon in the bar widget; details move to the tooltip."
}
},
"widget": {
"tooltip": "CPU {cpu}% RAM {ram}% · {n} processes"
},
"panel": {
"title": "Processes",
"refreshed_at": "Updated {time}",
"refresh": "Refresh",
"load": "Load",
"search_placeholder": "Filter by name, user or PID",
"sort_asc": "asc",
"sort_desc": "desc",
"col": {
"pid": "PID",
"cpu": "CPU%",
"cpu_raw": "CPU",
"mem": "MEM%",
"mem_raw": "MEM",
"rss": "RSS",
"stat": "S",
"cmd": "COMMAND"
},
"count": "{n} processes",
"no_processes": "No matching processes",
"kill_tip": "Kill {pid}"
}
}
+56
View File
@@ -0,0 +1,56 @@
--!nonstrict
-- Process Monitor — bar widget. Full mode shows "CPU% RAM% · count" text; icon-only
-- mode shows just a CPU icon colored by load, with the details in the tooltip so the
-- bar stays tiny. Clicking opens the panel.
local function cfg(key)
return noctalia.getConfig(key)
end
local function st(key)
return noctalia.state.get(key)
end
local function tr(key, subst)
return noctalia.tr(key, subst)
end
function update()
local stats = st("stats")
local cpu = stats and stats.cpu and stats.cpu.usagePercent
local ram = stats and stats.ram and stats.ram.usagePercent
local count = #(st("procs") or {})
local cpuInt = math.floor(cpu or 0)
local tooltip = tr("widget.tooltip", { cpu = cpuInt, ram = math.floor(ram or 0), n = count })
barWidget.setGlyph(cfg("glyph") or "cpu")
barWidget.setTooltip(tooltip)
if cfg("icon_only") then
barWidget.setText("")
-- color the icon by CPU load so a glance reads the machine state
if cpuInt >= 80 then
barWidget.setGlyphColor("error")
elseif cpuInt >= 50 then
barWidget.setGlyphColor("warning")
else
barWidget.setGlyphColor("on_surface")
end
return
end
barWidget.setGlyphColor("on_surface")
local text = string.format("CPU %d%% RAM %d%%", cpuInt, math.floor(ram or 0))
if cfg("show_count") then
text = text .. " · " .. count
end
barWidget.setText(text)
end
noctalia.state.watch("stats", update)
noctalia.state.watch("procs", update)
noctalia.setUpdateInterval(1000)
function onClick()
noctalia.togglePanel("weinguyen/procmon:panel")
end