diff --git a/procmon/README.md b/procmon/README.md new file mode 100644 index 0000000..f6b8340 --- /dev/null +++ b/procmon/README.md @@ -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. diff --git a/procmon/panel.luau b/procmon/panel.luau new file mode 100644 index 0000000..daf0e81 --- /dev/null +++ b/procmon/panel.luau @@ -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 diff --git a/procmon/plugin.toml b/procmon/plugin.toml new file mode 100644 index 0000000..e0d8f59 --- /dev/null +++ b/procmon/plugin.toml @@ -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" diff --git a/procmon/service.luau b/procmon/service.luau new file mode 100644 index 0000000..385af7b --- /dev/null +++ b/procmon/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 diff --git a/procmon/thumbnail.webp b/procmon/thumbnail.webp new file mode 100644 index 0000000..b614075 Binary files /dev/null and b/procmon/thumbnail.webp differ diff --git a/procmon/translations/en.json b/procmon/translations/en.json new file mode 100644 index 0000000..06793c6 --- /dev/null +++ b/procmon/translations/en.json @@ -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}" + } +} diff --git a/procmon/widget.luau b/procmon/widget.luau new file mode 100644 index 0000000..3340d56 --- /dev/null +++ b/procmon/widget.luau @@ -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