/** * Live process monitor — Linux /proc snapshot for the Processes tab + RPC. * * Always available on Linux (independent of PEARDATA_PROCESSES chart collector). * Caches successive samples so CPU / I/O rates stay meaningful across polls. */ import fs from 'fs' import path from 'path' import os from 'os' function chartsCollectorEnabled() { const v = process.env.PEARDATA_PROCESSES if (v === '0' || v === 'off' || v === 'false') return false if (v === '1' || v === 'on' || v === 'true') return true return os.platform() === 'linux' } const USER_HZ = 100 const LIST_CACHE_MS = 450 const PASSWD_CACHE_MS = 60_000 const MAX_LIMIT = 2000 const DEFAULT_LIMIT = 250 const SORTS = new Set(['cpu', 'rss', 'pid', 'name', 'io', 'threads', 'age', 'fds', 'state']) const FILTERS = new Set([ 'all', 'running', 'sleeping', 'zombie', 'stopped', 'highcpu', 'highmem', 'hasio', 'kernel', 'user', ]) /** @type {Map} */ let passwdMap = new Map() let passwdAt = 0 /** @type {{ ts: number, wallMs: number, byPid: Map, rows: object[], summary: object }|null} */ let cache = null /** @type {Map} */ let prevByPid = new Map() let prevWallMs = 0 /** * @typedef {{ * ticks: number, * utime: number, * stime: number, * readBytes: number, * writeBytes: number, * wallMs: number, * }} SamplePrev */ function pageSize() { try { return os.constants?.os?.PAGE_SIZE || 4096 } catch { return 4096 } } function hostCpus() { try { return os.cpus().length || 1 } catch { return 1 } } function loadavg1() { try { return os.loadavg()?.[0] ?? null } catch { return null } } function readFile(p) { try { return fs.readFileSync(p, 'utf8') } catch { return null } } function readLink(p) { try { return fs.readlinkSync(p) } catch { return null } } function refreshPasswd() { const now = Date.now() if (now - passwdAt < PASSWD_CACHE_MS && passwdMap.size) return passwdAt = now /** @type {Map} */ const map = new Map() const raw = readFile('/etc/passwd') if (raw) { for (const line of raw.split('\n')) { if (!line || line.startsWith('#')) continue const parts = line.split(':') if (parts.length < 3) continue map.set(String(parts[2]), parts[0]) } } passwdMap = map } function uidName(uid) { if (uid == null) return '' refreshPasswd() return passwdMap.get(String(uid)) || String(uid) } function bootTimeMs() { const up = readFile('/proc/uptime') if (!up) return Date.now() - os.uptime() * 1000 const sec = Number(up.trim().split(/\s+/)[0]) if (!Number.isFinite(sec)) return Date.now() - os.uptime() * 1000 return Date.now() - sec * 1000 } function shortCgroup(raw) { if (!raw) return '' const line = raw.trim().split('\n').pop() || '' // cgroup v2: "0::/system.slice/foo.service" const pathPart = line.includes('::') ? line.split('::')[1] : line.split(':').pop() const s = String(pathPart || '').replace(/^\//, '') if (!s || s === '.') return '' const bits = s.split('/') return bits.slice(-2).join('/').slice(0, 80) } function stateLabel(ch) { switch (ch) { case 'R': return 'running' case 'S': return 'sleeping' case 'D': return 'disk-sleep' case 'Z': return 'zombie' case 'T': case 't': return 'stopped' case 'I': return 'idle' default: return ch ? String(ch) : 'unknown' } } /** * Parse one /proc/[pid]/stat line. * @param {string} raw */ export function parseProcStat(raw) { const open = raw.indexOf('(') const close = raw.lastIndexOf(')') if (open < 0 || close < open) return null const name = raw.slice(open + 1, close) const rest = raw.slice(close + 2).split(/\s+/) // After comm: state ppid … utime stime … priority nice num_threads … starttime vsize rss pin … return { name, state: rest[0] || '?', ppid: Number(rest[1]) || 0, utime: Number(rest[11]) || 0, stime: Number(rest[12]) || 0, priority: Number(rest[15]) || 0, nice: Number(rest[16]) || 0, numThreads: Number(rest[17]) || 0, starttime: Number(rest[19]) || 0, vsize: Number(rest[20]) || 0, rssPages: Number(rest[21]) || 0, processor: Number(rest[36]) || 0, } } /** * @param {string} statusRaw */ export function parseProcStatus(statusRaw) { /** @type {Record} */ const m = {} for (const line of String(statusRaw || '').split('\n')) { const i = line.indexOf(':') if (i < 0) continue m[line.slice(0, i)] = line.slice(i + 1).trim() } const uid = (m.Uid || '').split(/\s+/)[0] const gid = (m.Gid || '').split(/\s+/)[0] return { uid: uid != null && uid !== '' ? Number(uid) : null, gid: gid != null && gid !== '' ? Number(gid) : null, threads: Number(m.Threads) || 0, vmRssKb: Number((m.VmRSS || '').split(/\s+/)[0]) || 0, vmSizeKb: Number((m.VmSize || '').split(/\s+/)[0]) || 0, vmSwapKb: Number((m.VmSwap || '').split(/\s+/)[0]) || 0, voluntaryCtxt: Number(m.voluntary_ctxt_switches) || 0, nonvoluntaryCtxt: Number(m.nonvoluntary_ctxt_switches) || 0, } } /** * Full /proc scrape with rates (relative to previous sample). * @param {{ detailPid?: number|null }} [opts] */ export function scanProcesses(opts = {}) { if (os.platform() !== 'linux') { return { supported: false, platform: os.platform(), ts: Date.now(), rows: [], summary: emptySummary(), } } let dirs try { dirs = fs.readdirSync('/proc') } catch { return { supported: false, platform: 'linux', ts: Date.now(), rows: [], summary: emptySummary(), error: 'cannot read /proc', } } const pageBytes = pageSize() const ncpu = hostCpus() const bootMs = bootTimeMs() const wallMs = Date.now() const detailPid = opts.detailPid != null ? Number(opts.detailPid) : null const dWallSec = prevWallMs && wallMs > prevWallMs ? (wallMs - prevWallMs) / 1000 : 0 /** @type {object[]} */ const rows = [] /** @type {Map} */ const nextPrev = new Map() let running = 0 let sleeping = 0 let zombie = 0 let stopped = 0 let idle = 0 let diskSleep = 0 let totalCpu = 0 let totalRss = 0 let totalThreads = 0 for (const ent of dirs) { if (!/^\d+$/.test(ent)) continue const pid = Number(ent) const base = path.join('/proc', ent) const statRaw = readFile(path.join(base, 'stat')) if (!statRaw) continue const st = parseProcStat(statRaw) if (!st) continue const statusRaw = readFile(path.join(base, 'status')) const status = statusRaw ? parseProcStatus(statusRaw) : null let readBytes = null let writeBytes = null const ioRaw = readFile(path.join(base, 'io')) if (ioRaw) { const rb = ioRaw.match(/^read_bytes:\s*(\d+)/m) const wb = ioRaw.match(/^write_bytes:\s*(\d+)/m) if (rb) readBytes = Number(rb[1]) || 0 if (wb) writeBytes = Number(wb[1]) || 0 } const ticks = st.utime + st.stime const prev = prevByPid.get(pid) let cpuPct = 0 let cpuUserPct = 0 let cpuSystemPct = 0 let ioReadKiBs = 0 let ioWriteKiBs = 0 if (prev && dWallSec > 0) { const dTicks = ticks - prev.ticks const dUser = st.utime - prev.utime const dSys = st.stime - prev.stime if (dTicks >= 0) { cpuPct = Math.min(100 * ncpu, (dTicks / USER_HZ / dWallSec) * 100) cpuUserPct = Math.min(100 * ncpu, (dUser / USER_HZ / dWallSec) * 100) cpuSystemPct = Math.min(100 * ncpu, (dSys / USER_HZ / dWallSec) * 100) } if (readBytes != null && writeBytes != null) { ioReadKiBs = Math.max(0, readBytes - prev.readBytes) / dWallSec / 1024 ioWriteKiBs = Math.max(0, writeBytes - prev.writeBytes) / dWallSec / 1024 } } nextPrev.set(pid, { ticks, utime: st.utime, stime: st.stime, readBytes: readBytes ?? 0, writeBytes: writeBytes ?? 0, wallMs, }) const rssMiB = status?.vmRssKb != null && status.vmRssKb > 0 ? status.vmRssKb / 1024 : (st.rssPages * pageBytes) / (1024 * 1024) const vszMiB = status?.vmSizeKb != null && status.vmSizeKb > 0 ? status.vmSizeKb / 1024 : st.vsize / (1024 * 1024) const startMs = bootMs + (st.starttime / USER_HZ) * 1000 const ageSec = Math.max(0, (wallMs - startMs) / 1000) const state = stateLabel(st.state) const threads = status?.threads || st.numThreads || 0 const uid = status?.uid const user = uidName(uid) let cmdline = '' const cmdRaw = readFile(path.join(base, 'cmdline')) if (cmdRaw) { cmdline = cmdRaw.replace(/\0/g, ' ').trim() } if (!cmdline) cmdline = st.name const cgroup = shortCgroup(readFile(path.join(base, 'cgroup')) || '') /** @type {object} */ const row = { pid, ppid: st.ppid, name: st.name, cmdline: cmdline.slice(0, 256), state: st.state, stateLabel: state, user, uid, gid: status?.gid ?? null, cpu: round(cpuPct, 2), cpuUser: round(cpuUserPct, 2), cpuSystem: round(cpuSystemPct, 2), mem: round(rssMiB, 2), vsz: round(vszMiB, 2), swap: round((status?.vmSwapKb || 0) / 1024, 2), threads, nice: st.nice, priority: st.priority, ioRead: round(ioReadKiBs, 2), ioWrite: round(ioWriteKiBs, 2), io: round(ioReadKiBs + ioWriteKiBs, 2), fds: null, startMs: Math.round(startMs), ageSec: Math.round(ageSec), cgroup, processor: st.processor, kernel: pid === 2 || st.ppid === 2 || /^(kthreadd|kworker|ksoftirq|kswapd|migration|rcu_|cpuhp|kdevtmpfs)/.test(st.name), } if (detailPid === pid) { row.cmdline = cmdline.slice(0, 4096) row.exe = readLink(path.join(base, 'exe')) row.cwd = readLink(path.join(base, 'cwd')) row.wchan = (readFile(path.join(base, 'wchan')) || '').trim().slice(0, 64) try { row.fds = fs.readdirSync(path.join(base, 'fd')).length } catch { row.fds = null } row.voluntaryCtxt = status?.voluntaryCtxt ?? 0 row.nonvoluntaryCtxt = status?.nonvoluntaryCtxt ?? 0 row.detail = true } rows.push(row) if (state === 'running') running++ else if (state === 'sleeping') sleeping++ else if (state === 'zombie') zombie++ else if (state === 'stopped') stopped++ else if (state === 'idle') idle++ else if (state === 'disk-sleep') diskSleep++ totalCpu += cpuPct totalRss += rssMiB totalThreads += threads } prevByPid = nextPrev prevWallMs = wallMs const summary = { total: rows.length, running, sleeping, idle, zombie, stopped, diskSleep, totalCpuPct: round(Math.min(100 * ncpu, totalCpu), 2), totalRssMiB: round(totalRss, 1), totalThreads, ncpu, load1: loadavg1() != null ? round(loadavg1(), 2) : null, pageBytes, } return { supported: true, platform: 'linux', ts: wallMs, rows, summary, } } function emptySummary() { return { total: 0, running: 0, sleeping: 0, idle: 0, zombie: 0, stopped: 0, diskSleep: 0, totalCpuPct: 0, totalRssMiB: 0, totalThreads: 0, ncpu: hostCpus(), load1: loadavg1() != null ? round(loadavg1(), 2) : null, pageBytes: pageSize(), } } function round(n, d) { const f = 10 ** d return Math.round(Number(n) * f) / f } /** * @param {object} row * @param {string} q */ export function processMatchesQuery(row, q) { const needle = String(q || '') .trim() .toLowerCase() if (!needle) return true const hay = [ row.pid, row.ppid, row.name, row.cmdline, row.user, row.uid, row.stateLabel, row.cgroup, row.exe, ] .filter((x) => x != null && x !== '') .join(' ') .toLowerCase() return hay.includes(needle) } /** * @param {object} row * @param {string} filter */ export function processMatchesFilter(row, filter) { switch (filter) { case 'running': return row.stateLabel === 'running' case 'sleeping': return row.stateLabel === 'sleeping' || row.stateLabel === 'idle' case 'zombie': return row.stateLabel === 'zombie' case 'stopped': return row.stateLabel === 'stopped' case 'highcpu': return Number(row.cpu) >= 5 case 'highmem': return Number(row.mem) >= 100 case 'hasio': return Number(row.io) > 0.5 case 'kernel': return Boolean(row.kernel) case 'user': return !row.kernel default: return true } } /** * @param {object[]} rows * @param {string} sort * @param {string} order */ export function sortProcesses(rows, sort, order) { const key = SORTS.has(sort) ? sort : 'cpu' const dir = order === 'asc' ? 1 : -1 const val = (r) => { switch (key) { case 'pid': return r.pid case 'name': return String(r.name || '').toLowerCase() case 'rss': return Number(r.mem) || 0 case 'io': return Number(r.io) || 0 case 'threads': return Number(r.threads) || 0 case 'age': return Number(r.ageSec) || 0 case 'fds': return Number(r.fds) || 0 case 'state': return String(r.stateLabel || '') case 'cpu': default: return Number(r.cpu) || 0 } } return [...rows].sort((a, b) => { const av = val(a) const bv = val(b) if (typeof av === 'string' && typeof bv === 'string') { return av.localeCompare(bv) * dir } return (Number(av) - Number(bv)) * dir }) } /** * Public RPC / REST entry. * @param {object} [args] */ export function listProcesses(args = {}) { const sort = SORTS.has(String(args.sort || '').toLowerCase()) ? String(args.sort).toLowerCase() : 'cpu' const order = String(args.order || 'desc').toLowerCase() === 'asc' ? 'asc' : 'desc' let limit = args.limit == null ? DEFAULT_LIMIT : Number(args.limit) if (!Number.isFinite(limit) || limit < 1) limit = DEFAULT_LIMIT limit = Math.min(MAX_LIMIT, Math.floor(limit)) let offset = args.offset == null ? 0 : Number(args.offset) if (!Number.isFinite(offset) || offset < 0) offset = 0 offset = Math.floor(offset) const filter = FILTERS.has(String(args.filter || '').toLowerCase()) ? String(args.filter).toLowerCase() : 'all' const q = args.q != null ? String(args.q) : '' const detailPid = args.pid != null && Number.isFinite(Number(args.pid)) ? Number(args.pid) : null const now = Date.now() const needDetail = detailPid != null const cacheOk = cache && !needDetail && now - cache.ts < LIST_CACHE_MS && cache.rows?.length let snap if (cacheOk) { snap = cache } else { const scanned = scanProcesses({ detailPid }) // Warm rates: first scan after boot has zeros — keep prev and allow second call snap = { ts: scanned.ts, wallMs: scanned.ts, byPid: prevByPid, rows: scanned.rows, summary: scanned.summary, supported: scanned.supported, platform: scanned.platform, error: scanned.error, } if (!needDetail) cache = snap } if (!snap.supported) { return { ok: true, supported: false, platform: snap.platform || os.platform(), ts: Date.now(), summary: snap.summary || emptySummary(), processes: [], totalMatched: 0, truncated: false, chartsEnabled: chartsCollectorEnabled(), error: snap.error || 'Process listing requires Linux /proc', } } let matched = snap.rows.filter( (r) => processMatchesFilter(r, filter) && processMatchesQuery(r, q) ) if (detailPid != null) { matched = matched.filter((r) => r.pid === detailPid) // If detail pid missing from cache path, force rescan if (!matched.length || !matched[0].detail) { const scanned = scanProcesses({ detailPid }) matched = scanned.rows.filter((r) => r.pid === detailPid) snap = { ...snap, rows: scanned.rows, summary: scanned.summary, ts: scanned.ts, } } } const sorted = sortProcesses(matched, sort, order) const slice = sorted.slice(offset, offset + limit) const topCpu = sortProcesses(snap.rows, 'cpu', 'desc') .slice(0, 5) .map((r) => ({ pid: r.pid, name: r.name, cpu: r.cpu, mem: r.mem })) const topMem = sortProcesses(snap.rows, 'rss', 'desc') .slice(0, 5) .map((r) => ({ pid: r.pid, name: r.name, cpu: r.cpu, mem: r.mem })) return { ok: true, supported: true, platform: 'linux', ts: snap.ts, summary: snap.summary, processes: slice, totalMatched: matched.length, total: snap.summary.total, offset, limit, sort, order, filter, q, truncated: offset + slice.length < matched.length, chartsEnabled: chartsCollectorEnabled(), topCpu, topMem, } } /** Test helper — reset rate / cache state. */ export function _resetProcessMonitorForTests() { cache = null prevByPid = new Map() prevWallMs = 0 passwdMap = new Map() passwdAt = 0 }