Update(procmon-plugin): Add keyboard navigation and reduce CPU sampling (#321)
* Add procmon plugin with live process panel New Noctalia plugin: bar widget shows CPU/RAM usage and process count; floating panel has a sortable, searchable process table with per-process kill. Background service samples ps and /proc stats and publishes via plugin state. * Throttle data-driven panel renders Rebuild table at most every 500ms to stay within the panel update CPU budget. User interactions still render immediately; only state watches and the tick go through maybeRender(). * Update procmon deps and fix timestamp precision Document head, grep, and cat as required commands. Correct refresh interval default to 1000 ms. Sample refreshedAtMs in milliseconds. * Add keyboard navigation and reduce CPU sampling - Panel: arrow keys move cursor, Ctrl+D kills selected, Ctrl+F focuses filter - Service: sample /proc stats every 1s, ps every 2s; 2s render floor - Widget: handle vertical bars, truncate text, use valid theme color - Bump version to 0.4.0, plugin_api 13
This commit is contained in:
+5
-5
@@ -25,14 +25,12 @@ usage (plus the process count). Click the widget to toggle the process panel:
|
|||||||
```sh
|
```sh
|
||||||
noctalia msg panel-toggle weinguyen/procmon:panel
|
noctalia msg panel-toggle weinguyen/procmon:panel
|
||||||
```
|
```
|
||||||
|
|
||||||
The panel shows CPU, RAM and swap bars with the 1/5/15-minute load averages,
|
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,
|
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
|
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
|
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
|
configured kill command against that PID (default `kill -TERM`). Zombie
|
||||||
processes are tinted with the error color so they stand out.
|
processes are tinted with the error color so they stand out.
|
||||||
|
|
||||||
The table refreshes on the interval set in the `refresh_interval` setting. The
|
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
|
panel re-renders automatically as new samples arrive, so the view stays live
|
||||||
while it is open.
|
while it is open.
|
||||||
@@ -48,13 +46,15 @@ while it is open.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Spawns processes.** A background service runs `ps` on every refresh
|
- **Spawns processes.** A background service runs `ps` every 2nd refresh
|
||||||
interval and `runAsync` runs the configured `kill_command` when a row's ✕ is
|
interval (the process table changes slowly) and reads `/proc` stats every
|
||||||
|
interval; `runAsync` runs the configured `kill_command` when a row's ✕ is
|
||||||
clicked. There is no confirmation dialog, so check the PID before clicking.
|
clicked. There is no confirmation dialog, so check the PID before clicking.
|
||||||
|
|
||||||
- The bar widget only renders data the service publishes; it never runs
|
- 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
|
commands. The panel runs the configured `kill_command` when a row's ✕ is
|
||||||
clicked.
|
clicked.
|
||||||
- Requires `plugin_api = 12`. CPU%, RAM%, swap and the 1/5/15-minute load
|
- Requires `plugin_api = 13`. CPU%, RAM%, swap and the 1/5/15-minute load
|
||||||
averages are sampled from `/proc` (`/proc/stat`, `/proc/meminfo`,
|
averages are sampled from `/proc` (`/proc/stat`, `/proc/meminfo`,
|
||||||
`/proc/loadavg`) by the service, so they work with no separate system-monitor
|
`/proc/loadavg`) by the service, so they work with no separate system-monitor
|
||||||
dependency.
|
dependency.
|
||||||
|
|||||||
+150
-14
@@ -5,6 +5,8 @@
|
|||||||
local sortKey = "cpu"
|
local sortKey = "cpu"
|
||||||
local sortDir = "desc"
|
local sortDir = "desc"
|
||||||
local filter = ""
|
local filter = ""
|
||||||
|
local filterRev = 0 -- bumped to reseed the filter input so it can grab focus
|
||||||
|
local focusFilterOnRender = false -- one-shot: focuses the filter on the next render
|
||||||
|
|
||||||
-- Per-column display unit: "pct" shows percent, "raw" shows the absolute
|
-- 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
|
-- figure. Clicking the %CPU/%MEM header toggles it. MEM raw = RSS in MB; CPU
|
||||||
@@ -19,6 +21,11 @@ local memUnit = "pct"
|
|||||||
-- processes are always visible. (200 rows * ~7 ui nodes blew the budget.)
|
-- processes are always visible. (200 rows * ~7 ui nodes blew the budget.)
|
||||||
local MAX_ROWS = 80
|
local MAX_ROWS = 80
|
||||||
|
|
||||||
|
-- Keyboard navigation state (btop-style): selectedIdx is the cursor position
|
||||||
|
-- in the currently visible (filtered/sorted/trimmed) list; 0 means nothing is
|
||||||
|
-- selected. Ctrl+D kills the selected process directly.
|
||||||
|
local selectedIdx = 0
|
||||||
|
|
||||||
-- Cheap fingerprint of everything the table shows, so the 1s tick only rebuilds
|
-- 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
|
-- 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
|
-- second exceeded the panel update CPU budget; sampling the first SIG_SAMPLE
|
||||||
@@ -29,10 +36,14 @@ local lastSig = nil
|
|||||||
-- Throttle data-driven renders. The service can sample faster than the panel can
|
-- 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
|
-- 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
|
-- 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()
|
-- the panel disabled. 2000ms bounds rebuilds to 1/2s, well under the budget.
|
||||||
-- immediately; only the data watches and the tick go through maybeRender().
|
-- The service samples stats every 1s and the process table every 2s, so a 2s
|
||||||
|
-- rebuild floor keeps the panel in lockstep with the table without re-rendering
|
||||||
|
-- the whole 80-row tree on every stats tick.
|
||||||
|
-- User interactions (toggle/filter/sort) still render() immediately; only the
|
||||||
|
-- data watches and the tick go through maybeRender().
|
||||||
local lastRenderMs = 0
|
local lastRenderMs = 0
|
||||||
local MIN_RENDER_MS = 500
|
local MIN_RENDER_MS = 2000
|
||||||
|
|
||||||
-- Kill column is a fixed width at the end of every row; all other columns are
|
-- 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.
|
-- flexGrow shares so the header and data rows always align and never overflow.
|
||||||
@@ -193,7 +204,7 @@ local function toggleMemUnit()
|
|||||||
render()
|
render()
|
||||||
end
|
end
|
||||||
|
|
||||||
local function dataRow(p)
|
local function dataRow(p, idx)
|
||||||
local stat = p.stat or "?"
|
local stat = p.stat or "?"
|
||||||
local statColor = stat:find("Z") and "error" or "on_surface"
|
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 memCell = memUnit == "raw" and fmtMem(p.rssMb) or string.format("%.1f", p.mem)
|
||||||
@@ -206,6 +217,20 @@ local function dataRow(p)
|
|||||||
{ stat, statColor },
|
{ stat, statColor },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- Cursor highlight mirrors the signal picker: a translucent fill plus the
|
||||||
|
-- text flipped to primary. Zombie rows keep their error tint so the warning
|
||||||
|
-- survives selection. The row key bakes in the selection state so a moved
|
||||||
|
-- cursor rebuilds exactly the rows whose selection flipped -- a stable pid
|
||||||
|
-- key would leave the fill stuck on rows the cursor passed over.
|
||||||
|
local selected = idx == selectedIdx
|
||||||
|
local function cellColor(c)
|
||||||
|
local color = c[2] or "on_surface"
|
||||||
|
if selected and color ~= "error" then
|
||||||
|
return "primary"
|
||||||
|
end
|
||||||
|
return color
|
||||||
|
end
|
||||||
|
|
||||||
local children = {}
|
local children = {}
|
||||||
for i, c in ipairs(cells) do
|
for i, c in ipairs(cells) do
|
||||||
table.insert(children, ui.label({
|
table.insert(children, ui.label({
|
||||||
@@ -214,7 +239,7 @@ local function dataRow(p)
|
|||||||
textAlign = COLS[i][2],
|
textAlign = COLS[i][2],
|
||||||
fontSize = 11,
|
fontSize = 11,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
color = c[2] or "on_surface",
|
color = cellColor(c),
|
||||||
}))
|
}))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -225,6 +250,7 @@ local function dataRow(p)
|
|||||||
textAlign = "start",
|
textAlign = "start",
|
||||||
fontSize = 11,
|
fontSize = 11,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
color = selected and "primary" or "on_surface",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
-- Kill button (fixed trailing width)
|
-- Kill button (fixed trailing width)
|
||||||
@@ -240,7 +266,19 @@ local function dataRow(p)
|
|||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return ui.row({ key = tostring(p.pid), gap = 6, align = "center" }, children)
|
return ui.row({
|
||||||
|
key = tostring(p.pid) .. (selected and "|sel" or ""),
|
||||||
|
gap = 6,
|
||||||
|
align = "center",
|
||||||
|
fill = selected and "primary/0.25" or nil,
|
||||||
|
radius = 6,
|
||||||
|
paddingH = 8,
|
||||||
|
paddingV = 2,
|
||||||
|
onClick = function()
|
||||||
|
selectedIdx = idx
|
||||||
|
render()
|
||||||
|
end,
|
||||||
|
}, children)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Header: clickable cells use ui.row onClick (keeps layout, so the grid stays
|
-- Header: clickable cells use ui.row onClick (keeps layout, so the grid stays
|
||||||
@@ -285,10 +323,13 @@ end
|
|||||||
|
|
||||||
-- ── main render ─────────────────────────────────────────────────────────────
|
-- ── main render ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function render()
|
-- The currently visible rows: filtered by `filter`, sorted by the active
|
||||||
|
-- column, capped at MAX_ROWS. Shared by render() and the keyboard handlers so
|
||||||
|
-- onKey resolves the cursor position against the exact same list the panel
|
||||||
|
-- draws (never trust an index computed from a stale render).
|
||||||
|
local function visibleList()
|
||||||
local procs = st("procs") or {}
|
local procs = st("procs") or {}
|
||||||
local f = filter:lower()
|
local f = filter:lower()
|
||||||
|
|
||||||
local list = {}
|
local list = {}
|
||||||
for _, p in ipairs(procs) do
|
for _, p in ipairs(procs) do
|
||||||
if f ~= "" then
|
if f ~= "" then
|
||||||
@@ -325,12 +366,29 @@ function render()
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
-- Bound the rendered rows so a 500-process box stays responsive.
|
-- Bound the rendered rows so a 500-process box stays responsive.
|
||||||
local shown = list
|
|
||||||
if #list > MAX_ROWS then
|
if #list > MAX_ROWS then
|
||||||
shown = {}
|
local shown = {}
|
||||||
for i = 1, MAX_ROWS do
|
for i = 1, MAX_ROWS do
|
||||||
shown[i] = list[i]
|
shown[i] = list[i]
|
||||||
end
|
end
|
||||||
|
return shown, #list
|
||||||
|
end
|
||||||
|
return list, #list
|
||||||
|
end
|
||||||
|
|
||||||
|
function render()
|
||||||
|
local wantsFocus = focusFilterOnRender
|
||||||
|
focusFilterOnRender = false
|
||||||
|
|
||||||
|
local shown, totalCount = visibleList()
|
||||||
|
-- Keep the cursor inside the visible list. A shrunk list (filter/sort/data
|
||||||
|
-- change) clamps up; an empty list clears the cursor.
|
||||||
|
if #shown == 0 then
|
||||||
|
selectedIdx = 0
|
||||||
|
elseif selectedIdx > #shown then
|
||||||
|
selectedIdx = #shown
|
||||||
|
elseif selectedIdx == 0 then
|
||||||
|
selectedIdx = 1
|
||||||
end
|
end
|
||||||
|
|
||||||
local body
|
local body
|
||||||
@@ -340,8 +398,8 @@ function render()
|
|||||||
})
|
})
|
||||||
else
|
else
|
||||||
local itemRows = {}
|
local itemRows = {}
|
||||||
for _, p in ipairs(shown) do
|
for i, p in ipairs(shown) do
|
||||||
table.insert(itemRows, dataRow(p))
|
table.insert(itemRows, dataRow(p, i))
|
||||||
end
|
end
|
||||||
body = ui.scroll({ flexGrow = 1, gap = 2, align = "stretch" }, itemRows)
|
body = ui.scroll({ flexGrow = 1, gap = 2, align = "stretch" }, itemRows)
|
||||||
end
|
end
|
||||||
@@ -358,11 +416,12 @@ function render()
|
|||||||
renderStats(),
|
renderStats(),
|
||||||
ui.row({ gap = 6, align = "center" }, {
|
ui.row({ gap = 6, align = "center" }, {
|
||||||
ui.input({
|
ui.input({
|
||||||
key = "filter",
|
key = "filter-" .. filterRev,
|
||||||
value = filter,
|
value = filter,
|
||||||
placeholder = tr("panel.search_placeholder"),
|
placeholder = tr("panel.search_placeholder"),
|
||||||
controlSize = "sm",
|
controlSize = "sm",
|
||||||
flexGrow = 1,
|
flexGrow = 1,
|
||||||
|
focus = wantsFocus,
|
||||||
onChange = function(v)
|
onChange = function(v)
|
||||||
filter = v
|
filter = v
|
||||||
render()
|
render()
|
||||||
@@ -397,7 +456,7 @@ function render()
|
|||||||
ui.row({ gap = 6, align = "center" }, buildHeader()),
|
ui.row({ gap = 6, align = "center" }, buildHeader()),
|
||||||
body,
|
body,
|
||||||
ui.row({ gap = 10, align = "center" }, {
|
ui.row({ gap = 10, align = "center" }, {
|
||||||
ui.label({ text = tr("panel.count", { n = #list }), fontSize = 11, color = "on_surface_variant" }),
|
ui.label({ text = tr("panel.count", { n = totalCount }), fontSize = 11, color = "on_surface_variant" }),
|
||||||
ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
|
ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
|
||||||
ui.label({ text = tr("panel.refresh"), fontSize = 11, color = "on_surface_variant" }),
|
ui.label({ text = tr("panel.refresh"), fontSize = 11, color = "on_surface_variant" }),
|
||||||
ui.slider({
|
ui.slider({
|
||||||
@@ -410,6 +469,9 @@ function render()
|
|||||||
}),
|
}),
|
||||||
ui.label({ text = err, fontSize = 11, color = "error" }),
|
ui.label({ text = err, fontSize = 11, color = "error" }),
|
||||||
}),
|
}),
|
||||||
|
ui.row({ gap = 8, align = "center" }, {
|
||||||
|
ui.label({ text = tr("panel.keys_hint"), fontSize = 10, color = "on_surface_variant" }),
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
lastSig = signature()
|
lastSig = signature()
|
||||||
end
|
end
|
||||||
@@ -452,9 +514,83 @@ function update()
|
|||||||
maybeRender()
|
maybeRender()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Keyboard control (btop-style). Chords must be listed in the panel's
|
||||||
|
-- `capture_keys` in plugin.toml, otherwise onKey never fires.
|
||||||
|
--
|
||||||
|
-- List mode: up/down move the cursor, Ctrl+D opens the signal picker for the
|
||||||
|
-- selected process, Ctrl+F focuses the filter box. Menu mode: up/down move the
|
||||||
|
-- signal highlight, Return sends it, Ctrl+D or Esc closes the menu. Ctrl-modifier
|
||||||
|
-- chords are used (not bare letters) so they never collide with the filter
|
||||||
|
-- input's text keys.
|
||||||
|
--
|
||||||
|
-- Keyboard chords. The runtime delivers chords as clean down/up pairs and
|
||||||
|
-- does not tag auto-repeat events, so holding a chord fires repeated presses.
|
||||||
|
-- The keyHeld guard (release-based, no time gate) blocks those repeats until
|
||||||
|
-- the release arrives; genuine taps clear on release, so they stay instant.
|
||||||
|
-- The short TTL only covers a rare missed release, never a tap.
|
||||||
|
local keyHeld = {}
|
||||||
|
local KEY_HELD_TTL_MS = 150
|
||||||
|
local function keyFresh(chord)
|
||||||
|
local now = noctalia.nowMs()
|
||||||
|
local heldAt = keyHeld[chord]
|
||||||
|
if heldAt and now - heldAt < KEY_HELD_TTL_MS then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
keyHeld[chord] = now
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function onKey(chord, pressed)
|
||||||
|
if not pressed then
|
||||||
|
keyHeld[chord] = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- Arrow keys repeat freely (holding Down scrolls the table / menu); the
|
||||||
|
-- guard only covers action chords where an auto-repeat would flip state.
|
||||||
|
if chord ~= "up" and chord ~= "down" and not keyFresh(chord) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if chord == "up" or chord == "down" then
|
||||||
|
local list = visibleList()
|
||||||
|
if #list > 0 then
|
||||||
|
selectedIdx = math.max(1, math.min(#list, selectedIdx + (chord == "down" and 1 or -1)))
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if chord == "ctrl+d" then
|
||||||
|
-- Kill the selected process directly (same configured kill_command the
|
||||||
|
-- row's ✕ button runs). No confirmation, matching the ✕ button.
|
||||||
|
local list = visibleList()
|
||||||
|
local p = list[selectedIdx]
|
||||||
|
if p then
|
||||||
|
killPid(p.pid)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if chord == "ctrl+f" then
|
||||||
|
filterRev += 1
|
||||||
|
focusFilterOnRender = true
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if chord == "escape" then
|
||||||
|
-- Swallow Escape so it never closes the whole panel.
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function onOpen(_context)
|
function onOpen(_context)
|
||||||
sortKey = cfg("sort_by") or "cpu"
|
sortKey = cfg("sort_by") or "cpu"
|
||||||
sortDir = "desc"
|
sortDir = "desc"
|
||||||
filter = ""
|
filter = ""
|
||||||
|
-- No auto-focus on the filter: the panel opens in list mode so the arrow
|
||||||
|
-- keys and Ctrl+D work immediately (btop-style). Press Ctrl+F to start
|
||||||
|
-- typing a filter; the box is focused then.
|
||||||
|
selectedIdx = 0
|
||||||
render()
|
render()
|
||||||
end
|
end
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
id = "weinguyen/procmon"
|
id = "weinguyen/procmon"
|
||||||
name = "Process Monitor"
|
name = "Process Monitor"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
plugin_api = 12
|
plugin_api = 13
|
||||||
author = "weinguyen"
|
author = "weinguyen"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
icon = "cpu"
|
icon = "cpu"
|
||||||
@@ -71,6 +71,7 @@ height = 660
|
|||||||
placement = "floating"
|
placement = "floating"
|
||||||
position = "center"
|
position = "center"
|
||||||
open_near_click = true
|
open_near_click = true
|
||||||
|
capture_keys = ["ctrl+f", "ctrl+d", "up", "down", "escape"]
|
||||||
|
|
||||||
[[service]]
|
[[service]]
|
||||||
id = "service"
|
id = "service"
|
||||||
|
|||||||
+36
-26
@@ -36,13 +36,14 @@ end
|
|||||||
|
|
||||||
-- `args` is placed last so the fixed fields are positional and everything
|
-- `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
|
-- after them is the full command line (which may contain spaces). The shell
|
||||||
-- pre-sorts by CPU and truncates to the top 120 rows so the Luau parse stays
|
-- pre-sorts by CPU and truncates to the top 80 rows so the Luau parse stays
|
||||||
-- tiny: parsing every process blew the async callback's CPU budget even with a
|
-- tiny: parsing every process blew the async callback's CPU budget even with a
|
||||||
-- single-pass gmatch. btm shows a screenful anyway, so top-120 is plenty.
|
-- single-pass gmatch. The panel caps at 80 rows (MAX_ROWS), so parsing more is
|
||||||
|
-- wasted work -- top-80 is exactly what gets displayed.
|
||||||
-- The trailing ##STAT/##MEM sections carry real total CPU/RAM from /proc so the
|
-- 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
|
-- 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.
|
-- average, so summing the top rows and capping at 100 always pegged the bar.
|
||||||
local PS_CMD = "ps -eo pid=,user=,%cpu=,%mem=,rss=,stat=,time=,args= --sort=-%cpu | head -n 120"
|
local PS_CMD = "ps -eo pid=,user=,%cpu=,%mem=,rss=,stat=,time=,args= --sort=-%cpu | head -n 80"
|
||||||
|
|
||||||
-- Real total CPU/RAM come from /proc in a SEPARATE, tiny command so the async
|
-- 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
|
-- callback that parses it stays far under the CPU budget. Folding the markers
|
||||||
@@ -170,35 +171,22 @@ local function sampleRam(raw)
|
|||||||
return math.floor(usedKb / totalKb * 100 + 0.5), math.floor(usedKb / 1024), math.floor(totalKb / 1024)
|
return math.floor(usedKb / totalKb * 100 + 0.5), math.floor(usedKb / 1024), math.floor(totalKb / 1024)
|
||||||
end
|
end
|
||||||
|
|
||||||
local sampling = false
|
-- inFlight counts outstanding async callbacks. sample() only starts when it is
|
||||||
|
-- 0, so a slow `ps` can never overlap the next tick's subprocesses -- overlapping
|
||||||
|
-- spawns were a big part of the CPU-budget blowout.
|
||||||
|
local inFlight = 0
|
||||||
|
local tick = 0
|
||||||
|
|
||||||
local function sample()
|
local function sample()
|
||||||
if sampling then
|
if inFlight > 0 then
|
||||||
return -- one in-flight sample at a time
|
return -- one in-flight sample at a time
|
||||||
end
|
end
|
||||||
sampling = true
|
tick += 1
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
-- Cheap /proc stats every tick: the bars stay real-time.
|
||||||
|
inFlight += 1
|
||||||
noctalia.runAsync(STATS_CMD, function(res)
|
noctalia.runAsync(STATS_CMD, function(res)
|
||||||
local ok, perr = pcall(function()
|
local ok, perr = pcall(function()
|
||||||
sampling = false
|
|
||||||
if res == nil or res.timedOut or res.exitCode ~= 0 then
|
if res == nil or res.timedOut or res.exitCode ~= 0 then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -215,10 +203,32 @@ local function sample()
|
|||||||
setState("err", "")
|
setState("err", "")
|
||||||
end)
|
end)
|
||||||
if not ok then
|
if not ok then
|
||||||
sampling = false
|
|
||||||
log("stats callback error: " .. tostring(perr))
|
log("stats callback error: " .. tostring(perr))
|
||||||
end
|
end
|
||||||
|
inFlight -= 1
|
||||||
end, PS_TIMEOUT_MS)
|
end, PS_TIMEOUT_MS)
|
||||||
|
|
||||||
|
-- The `ps` subprocess is the biggest CPU cost; the process table changes
|
||||||
|
-- slowly, so sample it every 2nd tick while stats stay fresh every tick.
|
||||||
|
if tick % 2 == 1 then
|
||||||
|
inFlight += 1
|
||||||
|
noctalia.runAsync(PS_CMD, function(res)
|
||||||
|
local ok, perr = pcall(function()
|
||||||
|
if res == nil or res.timedOut or res.exitCode ~= 0 then
|
||||||
|
local detail = res and (res.stderr or res.stdout or "") or "no result"
|
||||||
|
setState("err", detail)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local procs = parse(res.stdout)
|
||||||
|
setState("procs", procs)
|
||||||
|
setState("refreshedAtMs", os.time() * 1000)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
log("ps callback error: " .. tostring(perr))
|
||||||
|
end
|
||||||
|
inFlight -= 1
|
||||||
|
end, PS_TIMEOUT_MS)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Seed placeholders so subscribers have something before the first sample.
|
-- Seed placeholders so subscribers have something before the first sample.
|
||||||
|
|||||||
@@ -1,59 +1,60 @@
|
|||||||
{
|
{
|
||||||
"panel": {
|
|
||||||
"col": {
|
|
||||||
"cmd": "COMMAND",
|
|
||||||
"cpu": "CPU%",
|
|
||||||
"cpu_raw": "CPU",
|
|
||||||
"mem": "MEM%",
|
|
||||||
"mem_raw": "MEM",
|
|
||||||
"pid": "PID",
|
|
||||||
"rss": "RSS",
|
|
||||||
"stat": "S"
|
|
||||||
},
|
|
||||||
"count": "{n} processes",
|
|
||||||
"kill_tip": "Kill {pid}",
|
|
||||||
"load": "Load",
|
|
||||||
"no_processes": "No matching processes",
|
|
||||||
"refresh": "Refresh",
|
|
||||||
"refreshed_at": "Updated {time}",
|
|
||||||
"search_placeholder": "Filter by name, user or PID",
|
|
||||||
"sort_asc": "asc",
|
|
||||||
"sort_desc": "desc",
|
|
||||||
"title": "Processes"
|
|
||||||
},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"glyph": {
|
|
||||||
"description": "Glyph shown in the bar widget.",
|
|
||||||
"label": "Widget icon"
|
|
||||||
},
|
|
||||||
"icon_only": {
|
|
||||||
"description": "Show only a CPU icon in the bar widget; details move to the tooltip.",
|
|
||||||
"label": "Icon only"
|
|
||||||
},
|
|
||||||
"kill_command": {
|
|
||||||
"description": "Command run against a selected PID (the PID is appended). Empty uses kill -TERM.",
|
|
||||||
"label": "Kill command"
|
|
||||||
},
|
|
||||||
"refresh_interval": {
|
"refresh_interval": {
|
||||||
"description": "How often the process list is resampled.",
|
"label": "Refresh interval (ms)",
|
||||||
"label": "Refresh interval (ms)"
|
"description": "How often the process list is resampled."
|
||||||
},
|
|
||||||
"show_count": {
|
|
||||||
"description": "Append the number of processes to the bar widget text.",
|
|
||||||
"label": "Show process count"
|
|
||||||
},
|
},
|
||||||
"sort_by": {
|
"sort_by": {
|
||||||
"description": "Column the process table is sorted by when the panel opens.",
|
|
||||||
"label": "Default sort column",
|
"label": "Default sort column",
|
||||||
|
"description": "Column the process table is sorted by when the panel opens.",
|
||||||
"options": {
|
"options": {
|
||||||
"cmd": "Command",
|
|
||||||
"cpu": "CPU",
|
"cpu": "CPU",
|
||||||
"mem": "Memory",
|
"mem": "Memory",
|
||||||
"pid": "PID"
|
"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": {
|
"widget": {
|
||||||
"tooltip": "CPU {cpu}% RAM {ram}% · {n} processes"
|
"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}",
|
||||||
|
"keys_hint": "Ctrl+F filter · Ctrl+D kill selected · arrows move"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-21
@@ -1,7 +1,12 @@
|
|||||||
--!nonstrict
|
--!nonstrict
|
||||||
-- Process Monitor — bar widget. Full mode shows "CPU% RAM% · count" text; icon-only
|
-- Process Monitor — bar widget. Horizontal bars show "CPU% RAM%" text (truncated
|
||||||
-- mode shows just a CPU icon colored by load, with the details in the tooltip so the
|
-- to fit); vertical bars are too narrow for text, so they show only a CPU icon
|
||||||
-- bar stays tiny. Clicking opens the panel.
|
-- colored by load. Clicking opens the panel.
|
||||||
|
|
||||||
|
-- Read the bar orientation once at load (pomodoro pattern) so the widget renders
|
||||||
|
-- consistently instead of flickering between icon and text when isVertical() is
|
||||||
|
-- polled on every update.
|
||||||
|
local isVertical = barWidget.isVertical()
|
||||||
|
|
||||||
local function cfg(key)
|
local function cfg(key)
|
||||||
return noctalia.getConfig(key)
|
return noctalia.getConfig(key)
|
||||||
@@ -15,6 +20,14 @@ local function tr(key, subst)
|
|||||||
return noctalia.tr(key, subst)
|
return noctalia.tr(key, subst)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Cap the text so it fits the bar instead of spilling out the right edge.
|
||||||
|
local function trunc(s, n)
|
||||||
|
if #s <= n then
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
return s:sub(1, n - 1) .. "…"
|
||||||
|
end
|
||||||
|
|
||||||
function update()
|
function update()
|
||||||
local stats = st("stats")
|
local stats = st("stats")
|
||||||
local cpu = stats and stats.cpu and stats.cpu.usagePercent
|
local cpu = stats and stats.cpu and stats.cpu.usagePercent
|
||||||
@@ -23,28 +36,30 @@ function update()
|
|||||||
local cpuInt = math.floor(cpu or 0)
|
local cpuInt = math.floor(cpu or 0)
|
||||||
local tooltip = tr("widget.tooltip", { cpu = cpuInt, ram = math.floor(ram or 0), n = count })
|
local tooltip = tr("widget.tooltip", { cpu = cpuInt, ram = math.floor(ram or 0), n = count })
|
||||||
|
|
||||||
barWidget.setGlyph(cfg("glyph") or "cpu")
|
-- Color the icon by CPU load so a glance reads the machine state.
|
||||||
barWidget.setTooltip(tooltip)
|
-- Only real theme tokens (error/secondary/on_surface) — the runtime rejects
|
||||||
|
-- unknown names like "warning" and logs a warning per glyph per frame.
|
||||||
|
local color = "on_surface"
|
||||||
|
if cpuInt >= 80 then
|
||||||
|
color = "error"
|
||||||
|
elseif cpuInt >= 50 then
|
||||||
|
color = "secondary"
|
||||||
|
end
|
||||||
|
|
||||||
if cfg("icon_only") then
|
local children = { ui.glyph({ name = cfg("glyph") or "cpu", size = 14, color = color }) }
|
||||||
barWidget.setText("")
|
|
||||||
-- color the icon by CPU load so a glance reads the machine state
|
-- Vertical bars are too narrow for text; show only the icon so it fits.
|
||||||
if cpuInt >= 80 then
|
if not isVertical and not cfg("icon_only") then
|
||||||
barWidget.setGlyphColor("error")
|
local text = string.format("CPU %d%% RAM %d%%", cpuInt, math.floor(ram or 0))
|
||||||
elseif cpuInt >= 50 then
|
if cfg("show_count") then
|
||||||
barWidget.setGlyphColor("warning")
|
text = text .. " · " .. count
|
||||||
else
|
|
||||||
barWidget.setGlyphColor("on_surface")
|
|
||||||
end
|
end
|
||||||
return
|
table.insert(children, ui.label({ text = text, fontSize = 11 }))
|
||||||
end
|
end
|
||||||
|
|
||||||
barWidget.setGlyphColor("on_surface")
|
local container = isVertical and ui.column or ui.row
|
||||||
local text = string.format("CPU %d%% RAM %d%%", cpuInt, math.floor(ram or 0))
|
barWidget.render(container({ gap = 6, align = "center" }, children))
|
||||||
if cfg("show_count") then
|
barWidget.setTooltip(tooltip)
|
||||||
text = text .. " · " .. count
|
|
||||||
end
|
|
||||||
barWidget.setText(text)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
noctalia.state.watch("stats", update)
|
noctalia.state.watch("stats", update)
|
||||||
|
|||||||
Reference in New Issue
Block a user