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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user