--!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