Add process view
This commit is contained in:
+1
-1
@@ -41,7 +41,7 @@ PEARDATA_DEFAULT_ROLE=viewer
|
||||
# PEARDATA_CGROUPS_MAX=32
|
||||
# PEARDATA_SENSORS=1
|
||||
# PEARDATA_DISK_PARTITIONS=0
|
||||
# PEARDATA_PROCESSES=0
|
||||
# PEARDATA_PROCESSES=0 # default on for Linux; set 0 to disable top-* charts
|
||||
# PEARDATA_PROCESSES_TOP=8
|
||||
# PEARDATA_MYSQL=0
|
||||
# PEARDATA_APACHE=0
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
renderFleetCards,
|
||||
} from './ui/fleet.js'
|
||||
import { createLogsView } from './ui/logs.js'
|
||||
import { createProcessesView } from './ui/processes.js'
|
||||
import { createDataManager } from './ui/data-manager.js'
|
||||
import { formatMib, formatKilobitsPerSec } from './shared/format.js'
|
||||
import { chartOptionLabel } from './shared/container-names.js'
|
||||
@@ -246,6 +247,40 @@ const logsView = createLogsView({
|
||||
},
|
||||
})
|
||||
|
||||
const processesView = createProcessesView({
|
||||
els: {
|
||||
root: $('processes-view'),
|
||||
status: $('proc-status'),
|
||||
kpis: $('proc-kpis'),
|
||||
q: /** @type {HTMLInputElement|null} */ ($('proc-q')),
|
||||
filter: $('proc-filters'),
|
||||
sort: /** @type {HTMLSelectElement|null} */ ($('proc-sort')),
|
||||
refresh: /** @type {HTMLSelectElement|null} */ ($('proc-refresh-ms')),
|
||||
followBtn: $('proc-follow'),
|
||||
refreshBtn: $('proc-refresh'),
|
||||
treeBtn: $('proc-tree'),
|
||||
exportBtn: $('proc-export'),
|
||||
chartsBtn: $('proc-charts'),
|
||||
table: $('proc-table'),
|
||||
tbody: $('proc-tbody'),
|
||||
empty: $('proc-empty'),
|
||||
detail: $('proc-detail'),
|
||||
sparkCpu: null,
|
||||
sparkMem: null,
|
||||
topCpu: $('proc-top-cpu'),
|
||||
topMem: $('proc-top-mem'),
|
||||
},
|
||||
listProcesses: (args) => manager.request(Methods.listProcesses, args || {}),
|
||||
isConnected: () => Boolean(manager.active?.connected),
|
||||
onOpenCharts: (chartId) => {
|
||||
showView('charts')
|
||||
if (chartId) {
|
||||
metricsDashboard.scrollToChart?.(chartId)
|
||||
metricsDashboard.openFocus?.(chartId)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const dataManager = createDataManager({
|
||||
$,
|
||||
manager,
|
||||
@@ -1286,6 +1321,7 @@ async function seedHistory() {
|
||||
/* ─── Navigation ─── */
|
||||
function showView(name) {
|
||||
const prevLogs = !$('logs-view')?.classList.contains('hidden')
|
||||
const prevProc = !$('processes-view')?.classList.contains('hidden')
|
||||
document.querySelectorAll('.view').forEach((el) => el.classList.add('hidden'))
|
||||
const view = $(`${name}-view`)
|
||||
if (view) view.classList.remove('hidden')
|
||||
@@ -1293,11 +1329,13 @@ function showView(name) {
|
||||
btn.classList.toggle('active', btn.dataset.view === name)
|
||||
})
|
||||
if (prevLogs && name !== 'logs') logsView.leave?.()
|
||||
if (prevProc && name !== 'processes') processesView.leave?.()
|
||||
if (name === 'charts') {
|
||||
metricsDashboard.render()
|
||||
requestAnimationFrame(() => metricsDashboard.redrawVisible())
|
||||
}
|
||||
if (name === 'logs') logsView.enter()
|
||||
if (name === 'processes') processesView.enter()
|
||||
if (name === 'fleet') loadFleetView()
|
||||
if (name === 'settings') {
|
||||
syncSettingsUi()
|
||||
|
||||
@@ -84,7 +84,7 @@ Live retention is edited in the desktop **Settings → Data** tab (admin) and pe
|
||||
| `PEARDATA_SENSORS` | on (Linux) | `0` disables hwmon/thermal charts |
|
||||
| `PEARDATA_DISK_PARTITIONS` | off | `1` charts partition devices (higher cardinality) |
|
||||
| `PEARDATA_IRQ_MAX` | `24` | Max per-IRQ interrupt charts |
|
||||
| `PEARDATA_PROCESSES` | off | `1` enables process top CPU/RSS/IO/threads |
|
||||
| `PEARDATA_PROCESSES` | **on** (Linux) | Top process charts (`processes.top_*`). Set `0` to disable. Processes **tab** works whenever `/proc` is readable |
|
||||
| `PEARDATA_PROCESSES_TOP` | `8` | How many processes to keep in top charts (max 32) |
|
||||
| `PEARDATA_MYSQL` | off | MySQL/MariaDB TCP probe (+ optional stats URL) |
|
||||
| `PEARDATA_MYSQL_URL` | `127.0.0.1:3306` | MySQL host:port |
|
||||
|
||||
@@ -13,6 +13,7 @@ Related: [ROADMAP.md](./ROADMAP.md) Phase 6 · [DESKTOP.md](./DESKTOP.md) · [DA
|
||||
|---------|--------|
|
||||
| Overview | Compact 6-panel live grid + KPIs (auto-scaled RAM/net units); sparks + KPIs sync from history seed |
|
||||
| Charts | Sectioned metrics wall (TOC + cards), shared time bar, multi-dim series |
|
||||
| Processes | Live `/proc` table, KPIs, tree, detail drawer, CSV ([user-guide/processes.md](../user-guide/processes.md)) |
|
||||
| Containers | Docker/cgroup cards use **human names** (e.g. `dozzle · CPU`); ids stay `docker.cpu.<shortId>` |
|
||||
| Time | Charts presets (1m–6h) + play/pause; UI thin-history advisory; agent retention via Settings → Data |
|
||||
| Dimensions | Legend chips show/hide; chart type cycle (line/area/stacked/bar/pie) |
|
||||
|
||||
+4
-3
@@ -76,9 +76,10 @@ Have `pipeline.js` start each enabled collector; all emit `samples` batches into
|
||||
|
||||
### Process top-N collector
|
||||
|
||||
1. Set `PEARDATA_PROCESSES=1` (optional `PEARDATA_PROCESSES_TOP=8`).
|
||||
2. Charts: `processes.top_cpu`, `processes.top_rss` (dimensions = process comm names).
|
||||
3. Linux `/proc` only; safe no-op on other platforms.
|
||||
1. Default **on** for Linux (`PEARDATA_PROCESSES=0` to disable charts). Optional `PEARDATA_PROCESSES_TOP=8`.
|
||||
2. Charts: `processes.top_cpu` / `top_rss` / `top_io` / `top_threads` (dimensions = process comm names).
|
||||
3. Desktop **Processes** tab + RPC `listProcesses` / `GET /api/v3/processes` — per-PID live table (not just top-N charts).
|
||||
4. Linux `/proc` only; safe no-op on other platforms.
|
||||
|
||||
### Nginx stub_status plugin
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ Auth modes at handshake: public key (viewer), capability token / `pd1.` invite,
|
||||
| `queryData` | viewer | `{ chart, after, before, points, group, tier }` | Time series |
|
||||
| `getWeights` | viewer | see below | Metric Correlations / alert weights |
|
||||
| `queryLogs` | viewer* | see below | Anomaly / audit / journal lines |
|
||||
| `listProcesses` | viewer | sort / filter / q / limit | Live Linux `/proc` process table |
|
||||
| `getAllMetrics` | viewer | `{ format: json\|prometheus\|shell }` | Latest export |
|
||||
|
||||
\* `queryLogs` method role is viewer so anomaly search works for all dialers; **audit** and **journal** sources require **admin** inside the handler.
|
||||
@@ -84,6 +85,19 @@ REST parity: `GET /api/v*/weights`. Engine: `server/services/weights.js`.
|
||||
|
||||
Journal is **enabled by default** on Linux (`PEARDATA_JOURNAL=0` to disable). Installer grants `systemd-journal` to the agent user. REST: `GET /api/v*/logs`. Engine: `server/services/logs.js`. UI: [user-guide/logs.md](../user-guide/logs.md).
|
||||
|
||||
#### `listProcesses`
|
||||
|
||||
| Arg | Notes |
|
||||
|-----|--------|
|
||||
| `sort` | `cpu` (default) \| `rss` \| `pid` \| `name` \| `io` \| `threads` \| `age` \| `fds` \| `state` |
|
||||
| `order` | `desc` (default) \| `asc` |
|
||||
| `filter` | `all` \| `running` \| `sleeping` \| `zombie` \| `stopped` \| `highcpu` \| `highmem` \| `hasio` \| `kernel` \| `user` |
|
||||
| `q` | Substring over pid / name / cmdline / user / cgroup |
|
||||
| `limit` / `offset` | Cap ≤2000 |
|
||||
| `pid` | Optional — return one process with detail fields (fds, exe, cwd, …) |
|
||||
|
||||
Linux `/proc` only. Rates need two samples (~450ms cache). REST: `GET /api/v*/processes`. UI: [user-guide/processes.md](../user-guide/processes.md).
|
||||
|
||||
### Live subscriptions
|
||||
|
||||
| Method | Role | Args |
|
||||
|
||||
@@ -105,6 +105,7 @@ Single-agent returns one node. With `PEARDATA_PARENT=1`, `/nodes` and `/fleet` i
|
||||
| GET | `/api/v3/q?q=` | Full-text over chart ids/titles |
|
||||
| GET | `/api/v1\|v2\|v3/weights` | Metric Correlations + alert weights — see [Weights](#weights--metric-correlations); user guide: [weights-api](../user-guide/weights-api.md) |
|
||||
| GET | `/api/v1\|v2\|v3/logs` | Log search — `source=anomaly\|audit\|journal`, `q`, `since`, `until`, `priority`, `unit`, `limit` — [user-guide/logs](../user-guide/logs.md) |
|
||||
| GET | `/api/v1\|v2\|v3/processes` | Live process table — `sort`, `order`, `filter`, `q`, `limit`, `offset`, `pid` — [user-guide/processes](../user-guide/processes.md) |
|
||||
|
||||
### Alerts
|
||||
|
||||
|
||||
+95
@@ -53,6 +53,9 @@
|
||||
<button type="button" class="nav-link" data-view="logs">
|
||||
<span class="nav-ico">☰</span><span class="nav-label">Logs</span>
|
||||
</button>
|
||||
<button type="button" class="nav-link" data-view="processes">
|
||||
<span class="nav-ico">⧉</span><span class="nav-label">Processes</span>
|
||||
</button>
|
||||
<div class="nav-group-label">Fleet</div>
|
||||
<button type="button" class="nav-link" data-view="fleet">
|
||||
<span class="nav-ico">◎</span><span class="nav-label">Fleet</span>
|
||||
@@ -336,6 +339,98 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Processes -->
|
||||
<section id="processes-view" class="view hidden">
|
||||
<header class="page-header proc-header">
|
||||
<div>
|
||||
<p class="dash-kicker">Host</p>
|
||||
<h1 class="dash-title">Processes</h1>
|
||||
<p class="page-subtitle">Live /proc · rates · tree · detail · <kbd>/</kbd> search · <kbd>F</kbd> follow · <kbd>T</kbd> tree</p>
|
||||
</div>
|
||||
<div class="page-actions proc-header-actions">
|
||||
<button type="button" id="proc-follow" class="btn btn-ghost active" aria-pressed="true" title="Auto-refresh">Follow</button>
|
||||
<button type="button" id="proc-refresh" class="btn btn-ghost" title="Refresh now">Refresh</button>
|
||||
<button type="button" id="proc-tree" class="btn btn-ghost" aria-pressed="false" title="Parent/child tree">Tree</button>
|
||||
<button type="button" id="proc-export" class="btn btn-ghost" title="Export CSV">CSV</button>
|
||||
<button type="button" id="proc-charts" class="btn btn-primary" title="Open top process charts">Charts</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="proc-kpis" class="proc-kpis" aria-label="Process summary"></div>
|
||||
<div class="proc-toolbar">
|
||||
<input id="proc-q" type="search" placeholder="Filter pid, name, user, cmdline, cgroup…" autocomplete="off" spellcheck="false" />
|
||||
<div id="proc-filters" class="proc-filters" role="group" aria-label="Process filters">
|
||||
<button type="button" class="metrics-tf active" data-proc-filter="all">All</button>
|
||||
<button type="button" class="metrics-tf" data-proc-filter="running">Running</button>
|
||||
<button type="button" class="metrics-tf" data-proc-filter="highcpu">Hot CPU</button>
|
||||
<button type="button" class="metrics-tf" data-proc-filter="highmem">Hot mem</button>
|
||||
<button type="button" class="metrics-tf" data-proc-filter="hasio">I/O</button>
|
||||
<button type="button" class="metrics-tf" data-proc-filter="zombie">Zombie</button>
|
||||
<button type="button" class="metrics-tf" data-proc-filter="user">User</button>
|
||||
<button type="button" class="metrics-tf" data-proc-filter="kernel">Kernel</button>
|
||||
</div>
|
||||
<label class="metrics-group-label muted">
|
||||
Sort
|
||||
<select id="proc-sort" aria-label="Sort processes">
|
||||
<option value="cpu:desc">CPU ↓</option>
|
||||
<option value="cpu:asc">CPU ↑</option>
|
||||
<option value="rss:desc">RSS ↓</option>
|
||||
<option value="rss:asc">RSS ↑</option>
|
||||
<option value="io:desc">I/O ↓</option>
|
||||
<option value="threads:desc">Threads ↓</option>
|
||||
<option value="pid:asc">PID ↑</option>
|
||||
<option value="name:asc">Name A–Z</option>
|
||||
<option value="age:desc">Age ↓</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="metrics-group-label muted">
|
||||
Refresh
|
||||
<select id="proc-refresh-ms" aria-label="Refresh interval">
|
||||
<option value="1s">1s</option>
|
||||
<option value="2s" selected>2s</option>
|
||||
<option value="5s">5s</option>
|
||||
<option value="off">Manual</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p id="proc-status" class="muted proc-status" role="status"></p>
|
||||
<div class="proc-shell">
|
||||
<div class="proc-main">
|
||||
<div class="proc-table-wrap" id="proc-table-wrap">
|
||||
<table class="proc-table" id="proc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="pid">PID</th>
|
||||
<th>User</th>
|
||||
<th data-sort="name">Process</th>
|
||||
<th data-sort="state">State</th>
|
||||
<th data-sort="cpu">CPU</th>
|
||||
<th data-sort="rss">RSS</th>
|
||||
<th data-sort="io">I/O KiB/s</th>
|
||||
<th data-sort="threads">Thr</th>
|
||||
<th data-sort="age">Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="proc-tbody"></tbody>
|
||||
</table>
|
||||
<div id="proc-empty" class="proc-empty hidden" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="proc-side" aria-label="Process insights">
|
||||
<section class="proc-side-card">
|
||||
<h3>Top CPU</h3>
|
||||
<div id="proc-top-cpu" class="proc-top-list"></div>
|
||||
</section>
|
||||
<section class="proc-side-card">
|
||||
<h3>Top memory</h3>
|
||||
<div id="proc-top-mem" class="proc-top-list"></div>
|
||||
</section>
|
||||
<section class="proc-side-card proc-detail-card" id="proc-detail">
|
||||
<p class="muted proc-detail-empty">Select a process to inspect</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Fleet -->
|
||||
<section id="fleet-view" class="view hidden">
|
||||
<header class="page-header">
|
||||
|
||||
@@ -29,6 +29,7 @@ import { getStore } from '../services/store.js'
|
||||
import { getAnomalyEngine } from '../services/anomaly.js'
|
||||
import { computeWeights } from '../services/weights.js'
|
||||
import { queryLogs } from '../services/logs.js'
|
||||
import { listProcesses } from '../services/processes.js'
|
||||
import {
|
||||
listAlerts,
|
||||
getAlert,
|
||||
@@ -161,6 +162,10 @@ export function registerMonitorHandlers(session) {
|
||||
queryLogs({ ...(args || {}), role: session.role })
|
||||
)
|
||||
|
||||
session.respond('listProcesses', async (args) => listProcesses(args || {}), {
|
||||
hot: true,
|
||||
})
|
||||
|
||||
session.respond('getDbInfo', async () => {
|
||||
const db = getDb()
|
||||
if (!db) return { enabled: false }
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getCollector } from '../services/collector.js'
|
||||
import { getAnomalyEngine } from '../services/anomaly.js'
|
||||
import { computeWeights } from '../services/weights.js'
|
||||
import { queryLogs } from '../services/logs.js'
|
||||
import { listProcesses } from '../services/processes.js'
|
||||
import { listAlerts, getAlert } from '../services/alerts.js'
|
||||
import { getServerPublicKeyHex } from '../core/auth-keys.js'
|
||||
import { formatAllMetrics } from './formatters.js'
|
||||
@@ -186,6 +187,24 @@ export async function handleRest(pathname, query) {
|
||||
}
|
||||
return json(result)
|
||||
}
|
||||
// ── processes (live /proc table) ─────────────────────────
|
||||
if (
|
||||
path === '/api/v3/processes' ||
|
||||
path === '/api/v2/processes' ||
|
||||
path === '/api/v1/processes'
|
||||
) {
|
||||
const result = listProcesses({
|
||||
q: query.get('q') || query.get('query') || '',
|
||||
sort: query.get('sort') || undefined,
|
||||
order: query.get('order') || undefined,
|
||||
filter: query.get('filter') || undefined,
|
||||
limit: query.get('limit') || undefined,
|
||||
offset: query.get('offset') || undefined,
|
||||
pid: query.get('pid') || undefined,
|
||||
})
|
||||
return json(result)
|
||||
}
|
||||
|
||||
// ── logs (anomaly / audit / journal) ─────────────────────
|
||||
if (path === '/api/v3/logs' || path === '/api/v2/logs' || path === '/api/v1/logs') {
|
||||
const result = await queryLogs({
|
||||
|
||||
@@ -23,7 +23,10 @@ const log = logger.child('processes')
|
||||
|
||||
export function isProcessCollectorEnabled() {
|
||||
const v = process.env.PEARDATA_PROCESSES
|
||||
return v === '1' || v === 'on' || v === 'true'
|
||||
if (v === '0' || v === 'off' || v === 'false') return false
|
||||
if (v === '1' || v === 'on' || v === 'true') return true
|
||||
// Default on for Linux — powers Charts top-* series alongside the Processes tab
|
||||
return os.platform() === 'linux'
|
||||
}
|
||||
|
||||
function topN() {
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* 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<string, string>} */
|
||||
let passwdMap = new Map()
|
||||
let passwdAt = 0
|
||||
|
||||
/** @type {{ ts: number, wallMs: number, byPid: Map<number, SamplePrev>, rows: object[], summary: object }|null} */
|
||||
let cache = null
|
||||
/** @type {Map<number, SamplePrev>} */
|
||||
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<string, string>} */
|
||||
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<string, string>} */
|
||||
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<number, SamplePrev>} */
|
||||
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
|
||||
}
|
||||
@@ -75,6 +75,9 @@ export const MethodRoles = Object.freeze({
|
||||
/** Anomaly source: viewer; audit/journal enforced admin inside handler */
|
||||
queryLogs: Roles.viewer,
|
||||
|
||||
/** Live process table (Linux /proc) */
|
||||
listProcesses: Roles.viewer,
|
||||
|
||||
// alerts / thresholds (write)
|
||||
setAlertConfig: Roles.operator,
|
||||
ackAlert: Roles.operator,
|
||||
@@ -147,5 +150,6 @@ export const HotMethods = Object.freeze(
|
||||
Methods.subscribeMetrics,
|
||||
Methods.unsubscribeMetrics,
|
||||
Methods.getAllMetrics,
|
||||
Methods.listProcesses,
|
||||
])
|
||||
)
|
||||
|
||||
@@ -57,6 +57,69 @@ export function validateMethodArgs(method, args = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
case 'listProcesses': {
|
||||
const sort = String(args.sort || 'cpu').toLowerCase()
|
||||
const allowedSort = [
|
||||
'cpu',
|
||||
'rss',
|
||||
'pid',
|
||||
'name',
|
||||
'io',
|
||||
'threads',
|
||||
'age',
|
||||
'fds',
|
||||
'state',
|
||||
]
|
||||
if (!allowedSort.includes(sort)) {
|
||||
return { ok: false, error: `sort must be ${allowedSort.join('|')}` }
|
||||
}
|
||||
const order = String(args.order || 'desc').toLowerCase() === 'asc' ? 'asc' : 'desc'
|
||||
const filter = String(args.filter || 'all').toLowerCase()
|
||||
const allowedFilter = [
|
||||
'all',
|
||||
'running',
|
||||
'sleeping',
|
||||
'zombie',
|
||||
'stopped',
|
||||
'highcpu',
|
||||
'highmem',
|
||||
'hasio',
|
||||
'kernel',
|
||||
'user',
|
||||
]
|
||||
if (!allowedFilter.includes(filter)) {
|
||||
return { ok: false, error: `filter must be ${allowedFilter.join('|')}` }
|
||||
}
|
||||
const limit = args.limit == null ? 250 : Number(args.limit)
|
||||
if (!Number.isFinite(limit) || limit < 1 || limit > 2000) {
|
||||
return { ok: false, error: 'limit must be 1..2000' }
|
||||
}
|
||||
const offset = args.offset == null ? 0 : Number(args.offset)
|
||||
if (!Number.isFinite(offset) || offset < 0) {
|
||||
return { ok: false, error: 'offset must be ≥ 0' }
|
||||
}
|
||||
const pid =
|
||||
args.pid == null || args.pid === ''
|
||||
? null
|
||||
: Number(args.pid)
|
||||
if (pid != null && (!Number.isFinite(pid) || pid < 1)) {
|
||||
return { ok: false, error: 'pid must be a positive number' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
args: {
|
||||
...args,
|
||||
sort,
|
||||
order,
|
||||
filter,
|
||||
limit: Math.floor(limit),
|
||||
offset: Math.floor(offset),
|
||||
q: args.q != null ? String(args.q) : '',
|
||||
pid,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'setDisplayName': {
|
||||
const name = String(args.name ?? '').trim()
|
||||
if (!name) return { ok: false, error: 'name is required' }
|
||||
|
||||
@@ -4,6 +4,10 @@ import { isProcessCollectorEnabled, listProcStats } from '../server/services/col
|
||||
test('isProcessCollectorEnabled', (t) => {
|
||||
const prev = process.env.PEARDATA_PROCESSES
|
||||
delete process.env.PEARDATA_PROCESSES
|
||||
// Default on for Linux hosts
|
||||
if (process.platform === 'linux') t.ok(isProcessCollectorEnabled())
|
||||
else t.absent(isProcessCollectorEnabled())
|
||||
process.env.PEARDATA_PROCESSES = '0'
|
||||
t.absent(isProcessCollectorEnabled())
|
||||
process.env.PEARDATA_PROCESSES = '1'
|
||||
t.ok(isProcessCollectorEnabled())
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'brittle'
|
||||
import {
|
||||
parseProcStat,
|
||||
parseProcStatus,
|
||||
processMatchesQuery,
|
||||
processMatchesFilter,
|
||||
sortProcesses,
|
||||
listProcesses,
|
||||
_resetProcessMonitorForTests,
|
||||
} from '../server/services/processes.js'
|
||||
import { validateMethodArgs } from '../shared/schema.js'
|
||||
import { isProcessCollectorEnabled } from '../server/services/collectors/processes.js'
|
||||
|
||||
test('parseProcStat extracts comm and counters', (t) => {
|
||||
const raw =
|
||||
'1 (systemd) S 0 1 1 0 -1 4194560 123 0 0 0 10 20 0 0 20 0 1 0 12345 1000000 100 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
|
||||
const st = parseProcStat(raw)
|
||||
t.ok(st)
|
||||
t.is(st.name, 'systemd')
|
||||
t.is(st.state, 'S')
|
||||
t.is(st.ppid, 0)
|
||||
t.is(st.utime, 10)
|
||||
t.is(st.stime, 20)
|
||||
})
|
||||
|
||||
test('parseProcStatus reads uid and threads', (t) => {
|
||||
const s = parseProcStatus('Name:\tfoo\nUid:\t1000\t1000\t1000\t1000\nThreads:\t4\nVmRSS:\t2048 kB\n')
|
||||
t.is(s.uid, 1000)
|
||||
t.is(s.threads, 4)
|
||||
t.is(s.vmRssKb, 2048)
|
||||
})
|
||||
|
||||
test('process filter + query helpers', (t) => {
|
||||
const row = {
|
||||
pid: 42,
|
||||
name: 'nginx',
|
||||
cmdline: '/usr/sbin/nginx -g daemon',
|
||||
user: 'www',
|
||||
stateLabel: 'running',
|
||||
cpu: 12,
|
||||
mem: 200,
|
||||
io: 3,
|
||||
kernel: false,
|
||||
}
|
||||
t.ok(processMatchesQuery(row, 'nginx'))
|
||||
t.ok(processMatchesQuery(row, '42'))
|
||||
t.absent(processMatchesQuery(row, 'postgres'))
|
||||
t.ok(processMatchesFilter(row, 'running'))
|
||||
t.ok(processMatchesFilter(row, 'highcpu'))
|
||||
t.ok(processMatchesFilter(row, 'highmem'))
|
||||
t.ok(processMatchesFilter(row, 'user'))
|
||||
t.absent(processMatchesFilter(row, 'zombie'))
|
||||
})
|
||||
|
||||
test('sortProcesses orders by cpu desc', (t) => {
|
||||
const rows = [
|
||||
{ pid: 1, name: 'a', cpu: 1, mem: 9 },
|
||||
{ pid: 2, name: 'b', cpu: 9, mem: 1 },
|
||||
]
|
||||
const sorted = sortProcesses(rows, 'cpu', 'desc')
|
||||
t.is(sorted[0].pid, 2)
|
||||
t.is(sortProcesses(rows, 'rss', 'desc')[0].pid, 1)
|
||||
})
|
||||
|
||||
test('validateMethodArgs listProcesses', (t) => {
|
||||
const ok = validateMethodArgs('listProcesses', { sort: 'cpu', limit: 50 })
|
||||
t.ok(ok.ok)
|
||||
t.is(ok.args.sort, 'cpu')
|
||||
const bad = validateMethodArgs('listProcesses', { sort: 'nope' })
|
||||
t.absent(bad.ok)
|
||||
})
|
||||
|
||||
test('listProcesses returns shape', (t) => {
|
||||
_resetProcessMonitorForTests()
|
||||
const res = listProcesses({ limit: 20, sort: 'cpu' })
|
||||
t.ok(res.ok)
|
||||
t.ok(res.summary)
|
||||
t.ok(Array.isArray(res.processes))
|
||||
if (process.platform === 'linux') {
|
||||
t.ok(res.supported)
|
||||
t.ok(res.summary.total > 0)
|
||||
// Second call should populate CPU rates after prev sample
|
||||
_resetProcessMonitorForTests()
|
||||
listProcesses({ limit: 5 })
|
||||
const again = listProcesses({ limit: 5 })
|
||||
t.ok(again.processes.length > 0)
|
||||
t.ok(again.processes[0].pid > 0)
|
||||
t.ok(again.processes[0].name)
|
||||
} else {
|
||||
t.absent(res.supported)
|
||||
}
|
||||
})
|
||||
|
||||
test('isProcessCollectorEnabled defaults on linux', (t) => {
|
||||
const prev = process.env.PEARDATA_PROCESSES
|
||||
delete process.env.PEARDATA_PROCESSES
|
||||
if (process.platform === 'linux') t.ok(isProcessCollectorEnabled())
|
||||
else t.absent(isProcessCollectorEnabled())
|
||||
process.env.PEARDATA_PROCESSES = '0'
|
||||
t.absent(isProcessCollectorEnabled())
|
||||
process.env.PEARDATA_PROCESSES = '1'
|
||||
t.ok(isProcessCollectorEnabled())
|
||||
if (prev == null) delete process.env.PEARDATA_PROCESSES
|
||||
else process.env.PEARDATA_PROCESSES = prev
|
||||
})
|
||||
@@ -41,6 +41,7 @@ test('method roles cover monitoring surface', (t) => {
|
||||
'listChildPeers',
|
||||
'getWeights',
|
||||
'queryLogs',
|
||||
'listProcesses',
|
||||
'getStorageInfo',
|
||||
'getRetentionConfig',
|
||||
'setRetentionConfig',
|
||||
|
||||
+663
@@ -0,0 +1,663 @@
|
||||
/**
|
||||
* Processes tab — live /proc table with sparklines, filters, and detail drawer.
|
||||
*/
|
||||
|
||||
const REFRESH = { '1s': 1000, '2s': 2000, '5s': 5000, off: 0 }
|
||||
const HISTORY_LEN = 60
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* els: {
|
||||
* root: HTMLElement|null,
|
||||
* status: HTMLElement|null,
|
||||
* kpis: HTMLElement|null,
|
||||
* q: HTMLInputElement|null,
|
||||
* filter: HTMLElement|null,
|
||||
* sort: HTMLSelectElement|null,
|
||||
* refresh: HTMLSelectElement|null,
|
||||
* followBtn: HTMLElement|null,
|
||||
* refreshBtn: HTMLElement|null,
|
||||
* treeBtn: HTMLElement|null,
|
||||
* exportBtn: HTMLElement|null,
|
||||
* chartsBtn: HTMLElement|null,
|
||||
* table: HTMLElement|null,
|
||||
* tbody: HTMLElement|null,
|
||||
* empty: HTMLElement|null,
|
||||
* detail: HTMLElement|null,
|
||||
* sparkCpu: HTMLCanvasElement|null,
|
||||
* sparkMem: HTMLCanvasElement|null,
|
||||
* topCpu: HTMLElement|null,
|
||||
* topMem: HTMLElement|null,
|
||||
* },
|
||||
* listProcesses: (args: object) => Promise<object>,
|
||||
* isConnected?: () => boolean,
|
||||
* onOpenCharts?: (chartId?: string) => void,
|
||||
* }} opts
|
||||
*/
|
||||
export function createProcessesView(opts) {
|
||||
const state = {
|
||||
sort: 'cpu',
|
||||
order: 'desc',
|
||||
filter: 'all',
|
||||
q: '',
|
||||
refreshKey: '2s',
|
||||
follow: true,
|
||||
tree: false,
|
||||
loading: false,
|
||||
gen: 0,
|
||||
/** @type {object|null} */
|
||||
last: null,
|
||||
/** @type {number|null} */
|
||||
selectedPid: null,
|
||||
/** @type {ReturnType<typeof setInterval>|null} */
|
||||
timer: null,
|
||||
/** @type {Map<number, { cpu: number[], mem: number[] }>} */
|
||||
history: new Map(),
|
||||
/** @type {Map<number, object>} */
|
||||
byPid: new Map(),
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function connected() {
|
||||
return opts.isConnected?.() !== false
|
||||
}
|
||||
|
||||
function formatAge(sec) {
|
||||
const s = Math.max(0, Number(sec) || 0)
|
||||
if (s < 60) return `${Math.round(s)}s`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`
|
||||
if (s < 86400) return `${(s / 3600).toFixed(1)}h`
|
||||
return `${(s / 86400).toFixed(1)}d`
|
||||
}
|
||||
|
||||
function formatMem(mib) {
|
||||
const n = Number(mib) || 0
|
||||
if (n >= 1024) return `${(n / 1024).toFixed(2)} GiB`
|
||||
if (n >= 10) return `${n.toFixed(0)} MiB`
|
||||
return `${n.toFixed(1)} MiB`
|
||||
}
|
||||
|
||||
function barWidth(pct, max = 100) {
|
||||
const p = Math.max(0, Math.min(100, ((Number(pct) || 0) / max) * 100))
|
||||
return `${p.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function stateClass(label) {
|
||||
switch (label) {
|
||||
case 'running':
|
||||
return 'is-run'
|
||||
case 'zombie':
|
||||
return 'is-zombie'
|
||||
case 'stopped':
|
||||
return 'is-stop'
|
||||
case 'disk-sleep':
|
||||
return 'is-disk'
|
||||
default:
|
||||
return 'is-sleep'
|
||||
}
|
||||
}
|
||||
|
||||
function pushHistory(row) {
|
||||
let h = state.history.get(row.pid)
|
||||
if (!h) {
|
||||
h = { cpu: [], mem: [] }
|
||||
state.history.set(row.pid, h)
|
||||
}
|
||||
h.cpu.push(Number(row.cpu) || 0)
|
||||
h.mem.push(Number(row.mem) || 0)
|
||||
if (h.cpu.length > HISTORY_LEN) h.cpu.shift()
|
||||
if (h.mem.length > HISTORY_LEN) h.mem.shift()
|
||||
}
|
||||
|
||||
function pruneHistory(alive) {
|
||||
for (const pid of [...state.history.keys()]) {
|
||||
if (!alive.has(pid)) state.history.delete(pid)
|
||||
}
|
||||
}
|
||||
|
||||
function paintSpark(canvas, values, color) {
|
||||
if (!canvas) return
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1)
|
||||
const w = canvas.clientWidth || 160
|
||||
const h = canvas.clientHeight || 40
|
||||
if (canvas.width !== Math.floor(w * dpr) || canvas.height !== Math.floor(h * dpr)) {
|
||||
canvas.width = Math.floor(w * dpr)
|
||||
canvas.height = Math.floor(h * dpr)
|
||||
}
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
const data = values?.length ? values : [0]
|
||||
const max = Math.max(1, ...data)
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
data.forEach((v, i) => {
|
||||
const x = data.length === 1 ? w / 2 : (i / (data.length - 1)) * (w - 2) + 1
|
||||
const y = h - 2 - (v / max) * (h - 6)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.stroke()
|
||||
ctx.lineTo(w - 1, h - 1)
|
||||
ctx.lineTo(1, h - 1)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = color.replace(')', ', 0.12)').replace('rgb', 'rgba').replace('#', '')
|
||||
// simple fill under curve
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, h)
|
||||
grad.addColorStop(0, color.length === 7 ? color + '33' : 'rgba(52,211,153,0.2)')
|
||||
grad.addColorStop(1, 'transparent')
|
||||
ctx.fillStyle = grad
|
||||
ctx.beginPath()
|
||||
data.forEach((v, i) => {
|
||||
const x = data.length === 1 ? w / 2 : (i / (data.length - 1)) * (w - 2) + 1
|
||||
const y = h - 2 - (v / max) * (h - 6)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.lineTo(w - 1, h - 1)
|
||||
ctx.lineTo(1, h - 1)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
function renderKpis(summary) {
|
||||
const el = opts.els.kpis
|
||||
if (!el || !summary) return
|
||||
const cards = [
|
||||
{ label: 'Processes', value: summary.total, sub: `${summary.running || 0} run` },
|
||||
{
|
||||
label: 'CPU',
|
||||
value: `${Number(summary.totalCpuPct || 0).toFixed(1)}%`,
|
||||
sub: `${summary.ncpu || 1} cores`,
|
||||
},
|
||||
{ label: 'RSS', value: formatMem(summary.totalRssMiB), sub: 'resident' },
|
||||
{ label: 'Threads', value: summary.totalThreads, sub: 'live sum' },
|
||||
{
|
||||
label: 'Load 1m',
|
||||
value: summary.load1 != null ? Number(summary.load1).toFixed(2) : '—',
|
||||
sub: 'system',
|
||||
},
|
||||
{
|
||||
label: 'Zombies',
|
||||
value: summary.zombie || 0,
|
||||
sub: summary.zombie ? 'reap me' : 'clean',
|
||||
warn: summary.zombie > 0,
|
||||
},
|
||||
]
|
||||
el.innerHTML = cards
|
||||
.map(
|
||||
(c) => `
|
||||
<div class="proc-kpi${c.warn ? ' is-warn' : ''}">
|
||||
<span class="proc-kpi-label">${escapeHtml(c.label)}</span>
|
||||
<strong class="proc-kpi-value">${escapeHtml(String(c.value))}</strong>
|
||||
<span class="proc-kpi-sub muted">${escapeHtml(c.sub)}</span>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function renderTops(res) {
|
||||
const mk = (el, rows, key) => {
|
||||
if (!el) return
|
||||
if (!rows?.length) {
|
||||
el.innerHTML = '<p class="muted">No data yet</p>'
|
||||
return
|
||||
}
|
||||
el.innerHTML = rows
|
||||
.map((r, i) => {
|
||||
const v = key === 'cpu' ? `${Number(r.cpu).toFixed(1)}%` : formatMem(r.mem)
|
||||
return `<button type="button" class="proc-top-row" data-pid="${r.pid}">
|
||||
<span class="proc-top-rank">${i + 1}</span>
|
||||
<span class="proc-top-name" title="${escapeHtml(r.name)}">${escapeHtml(r.name)}</span>
|
||||
<span class="proc-top-val">${escapeHtml(v)}</span>
|
||||
</button>`
|
||||
})
|
||||
.join('')
|
||||
el.querySelectorAll('[data-pid]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
selectPid(Number(btn.getAttribute('data-pid')))
|
||||
})
|
||||
})
|
||||
}
|
||||
mk(opts.els.topCpu, res.topCpu, 'cpu')
|
||||
mk(opts.els.topMem, res.topMem, 'mem')
|
||||
}
|
||||
|
||||
function buildTreeRows(rows) {
|
||||
/** @type {Map<number, object[]>} */
|
||||
const children = new Map()
|
||||
const byPid = new Map(rows.map((r) => [r.pid, r]))
|
||||
for (const r of rows) {
|
||||
const parent = byPid.has(r.ppid) ? r.ppid : 0
|
||||
if (!children.has(parent)) children.set(parent, [])
|
||||
children.get(parent).push(r)
|
||||
}
|
||||
for (const list of children.values()) {
|
||||
list.sort((a, b) => (b.cpu || 0) - (a.cpu || 0))
|
||||
}
|
||||
/** @type {Array<object & { depth: number }>} */
|
||||
const out = []
|
||||
const walk = (pid, depth) => {
|
||||
for (const r of children.get(pid) || []) {
|
||||
out.push({ ...r, depth })
|
||||
walk(r.pid, depth + 1)
|
||||
}
|
||||
}
|
||||
walk(0, 0)
|
||||
// Orphans whose parent wasn't in the filtered set
|
||||
const seen = new Set(out.map((r) => r.pid))
|
||||
for (const r of rows) {
|
||||
if (!seen.has(r.pid)) out.push({ ...r, depth: 0 })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function renderTable(res) {
|
||||
const tbody = opts.els.tbody
|
||||
const empty = opts.els.empty
|
||||
if (!tbody) return
|
||||
let rows = res.processes || []
|
||||
if (state.tree) rows = buildTreeRows(rows)
|
||||
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = ''
|
||||
if (empty) {
|
||||
empty.classList.remove('hidden')
|
||||
empty.textContent = connected()
|
||||
? res.supported === false
|
||||
? res.error || 'Process listing requires a Linux agent with /proc'
|
||||
: 'No processes match this filter'
|
||||
: 'Connect an agent to monitor processes'
|
||||
}
|
||||
return
|
||||
}
|
||||
empty?.classList.add('hidden')
|
||||
|
||||
const ncpu = res.summary?.ncpu || 1
|
||||
tbody.innerHTML = rows
|
||||
.map((r) => {
|
||||
const depth = r.depth || 0
|
||||
const sel = state.selectedPid === r.pid ? ' is-selected' : ''
|
||||
const indent = depth ? ` style="padding-left:${12 + depth * 14}px"` : ''
|
||||
return `<tr class="proc-row${sel}" data-pid="${r.pid}" tabindex="0">
|
||||
<td class="proc-pid">${r.pid}</td>
|
||||
<td class="proc-user" title="${escapeHtml(r.user || '')}">${escapeHtml(r.user || '—')}</td>
|
||||
<td class="proc-name"${indent}>
|
||||
<span class="proc-comm">${escapeHtml(r.name)}</span>
|
||||
<span class="proc-cmd muted" title="${escapeHtml(r.cmdline || '')}">${escapeHtml(
|
||||
(r.cmdline || '').slice(0, 72)
|
||||
)}</span>
|
||||
</td>
|
||||
<td class="proc-state"><span class="proc-pill ${stateClass(r.stateLabel)}">${escapeHtml(
|
||||
r.stateLabel || r.state || '?'
|
||||
)}</span></td>
|
||||
<td class="proc-num proc-cpu" title="user ${r.cpuUser}% · sys ${r.cpuSystem}%">
|
||||
<span class="proc-bar-track"><i style="width:${barWidth(r.cpu, 100 * ncpu)}"></i></span>
|
||||
${Number(r.cpu).toFixed(1)}%
|
||||
</td>
|
||||
<td class="proc-num">
|
||||
<span class="proc-bar-track proc-bar-mem"><i style="width:${barWidth(
|
||||
Math.min(100, (Number(r.mem) / Math.max(1, res.summary?.totalRssMiB || 1)) * 100)
|
||||
)}"></i></span>
|
||||
${escapeHtml(formatMem(r.mem))}
|
||||
</td>
|
||||
<td class="proc-num">${Number(r.io).toFixed(1)}</td>
|
||||
<td class="proc-num">${r.threads}</td>
|
||||
<td class="proc-num muted">${formatAge(r.ageSec)}</td>
|
||||
</tr>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
tbody.querySelectorAll('.proc-row').forEach((tr) => {
|
||||
const pid = Number(tr.getAttribute('data-pid'))
|
||||
tr.addEventListener('click', () => selectPid(pid))
|
||||
tr.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault()
|
||||
selectPid(pid)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function selectPid(pid) {
|
||||
state.selectedPid = pid
|
||||
opts.els.tbody?.querySelectorAll('.proc-row').forEach((tr) => {
|
||||
tr.classList.toggle('is-selected', Number(tr.getAttribute('data-pid')) === pid)
|
||||
})
|
||||
await renderDetail(pid)
|
||||
}
|
||||
|
||||
async function renderDetail(pid) {
|
||||
const panel = opts.els.detail
|
||||
if (!panel) return
|
||||
if (!pid) {
|
||||
panel.innerHTML = '<p class="muted proc-detail-empty">Select a process to inspect</p>'
|
||||
return
|
||||
}
|
||||
panel.classList.add('is-loading')
|
||||
let row = state.byPid.get(pid)
|
||||
try {
|
||||
const res = await opts.listProcesses({
|
||||
pid,
|
||||
limit: 1,
|
||||
sort: state.sort,
|
||||
order: state.order,
|
||||
})
|
||||
if (res.processes?.[0]) {
|
||||
row = res.processes[0]
|
||||
state.byPid.set(pid, row)
|
||||
pushHistory(row)
|
||||
}
|
||||
} catch {
|
||||
// keep cached row
|
||||
}
|
||||
panel.classList.remove('is-loading')
|
||||
if (!row) {
|
||||
panel.innerHTML = `<p class="muted">PID ${pid} gone</p>`
|
||||
return
|
||||
}
|
||||
const hist = state.history.get(pid) || { cpu: [], mem: [] }
|
||||
panel.innerHTML = `
|
||||
<header class="proc-detail-head">
|
||||
<div>
|
||||
<p class="proc-detail-kicker muted">PID ${row.pid} · ppid ${row.ppid}</p>
|
||||
<h3>${escapeHtml(row.name)}</h3>
|
||||
<p class="proc-detail-user muted">${escapeHtml(row.user || '—')} · nice ${row.nice} · cpu${
|
||||
row.processor ?? '—'
|
||||
}</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost proc-detail-close" title="Close">✕</button>
|
||||
</header>
|
||||
<p class="proc-detail-cmd" title="${escapeHtml(row.cmdline || '')}">${escapeHtml(
|
||||
row.cmdline || '—'
|
||||
)}</p>
|
||||
<div class="proc-detail-sparks">
|
||||
<div>
|
||||
<span class="muted">CPU</span>
|
||||
<canvas class="proc-spark" data-spark="cpu" height="48"></canvas>
|
||||
</div>
|
||||
<div>
|
||||
<span class="muted">RSS</span>
|
||||
<canvas class="proc-spark" data-spark="mem" height="48"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="proc-detail-grid">
|
||||
<div><dt>State</dt><dd>${escapeHtml(row.stateLabel || row.state)}</dd></div>
|
||||
<div><dt>CPU</dt><dd>${Number(row.cpu).toFixed(2)}% <span class="muted">(u ${Number(
|
||||
row.cpuUser
|
||||
).toFixed(1)} / s ${Number(row.cpuSystem).toFixed(1)})</span></dd></div>
|
||||
<div><dt>RSS</dt><dd>${escapeHtml(formatMem(row.mem))}</dd></div>
|
||||
<div><dt>VSZ</dt><dd>${escapeHtml(formatMem(row.vsz))}</dd></div>
|
||||
<div><dt>Swap</dt><dd>${escapeHtml(formatMem(row.swap))}</dd></div>
|
||||
<div><dt>Threads</dt><dd>${row.threads}</dd></div>
|
||||
<div><dt>I/O</dt><dd>${Number(row.ioRead).toFixed(1)} / ${Number(row.ioWrite).toFixed(
|
||||
1
|
||||
)} KiB/s</dd></div>
|
||||
<div><dt>FDs</dt><dd>${row.fds != null ? row.fds : '—'}</dd></div>
|
||||
<div><dt>Age</dt><dd>${formatAge(row.ageSec)}</dd></div>
|
||||
<div><dt>Cgroup</dt><dd title="${escapeHtml(row.cgroup || '')}">${escapeHtml(
|
||||
row.cgroup || '—'
|
||||
)}</dd></div>
|
||||
<div><dt>Exe</dt><dd title="${escapeHtml(row.exe || '')}">${escapeHtml(
|
||||
row.exe || '—'
|
||||
)}</dd></div>
|
||||
<div><dt>Cwd</dt><dd title="${escapeHtml(row.cwd || '')}">${escapeHtml(
|
||||
row.cwd || '—'
|
||||
)}</dd></div>
|
||||
<div><dt>Wchan</dt><dd>${escapeHtml(row.wchan || '—')}</dd></div>
|
||||
<div><dt>Ctxt</dt><dd>${row.voluntaryCtxt ?? '—'} vol / ${
|
||||
row.nonvoluntaryCtxt ?? '—'
|
||||
} nonvol</dd></div>
|
||||
</dl>
|
||||
`
|
||||
const cpuCanvas = panel.querySelector('[data-spark="cpu"]')
|
||||
const memCanvas = panel.querySelector('[data-spark="mem"]')
|
||||
paintSpark(/** @type {HTMLCanvasElement} */ (cpuCanvas), hist.cpu, '#34d399')
|
||||
paintSpark(/** @type {HTMLCanvasElement} */ (memCanvas), hist.mem, '#38bdf8')
|
||||
panel.querySelector('.proc-detail-close')?.addEventListener('click', () => {
|
||||
state.selectedPid = null
|
||||
opts.els.tbody?.querySelectorAll('.proc-row').forEach((tr) => tr.classList.remove('is-selected'))
|
||||
renderDetail(null)
|
||||
})
|
||||
}
|
||||
|
||||
function setStatus(text, isError = false) {
|
||||
const el = opts.els.status
|
||||
if (!el) return
|
||||
el.textContent = text
|
||||
el.classList.toggle('is-error', isError)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!connected()) {
|
||||
setStatus('Offline — connect an agent', true)
|
||||
renderTable({ processes: [], supported: false })
|
||||
renderKpis(null)
|
||||
return
|
||||
}
|
||||
const gen = ++state.gen
|
||||
state.loading = true
|
||||
opts.els.root?.classList.add('is-loading')
|
||||
try {
|
||||
const res = await opts.listProcesses({
|
||||
q: state.q,
|
||||
sort: state.sort,
|
||||
order: state.order,
|
||||
filter: state.filter,
|
||||
limit: state.tree ? 800 : 400,
|
||||
})
|
||||
if (gen !== state.gen) return
|
||||
state.last = res
|
||||
state.byPid = new Map((res.processes || []).map((r) => [r.pid, r]))
|
||||
const alive = new Set(state.byPid.keys())
|
||||
for (const r of res.processes || []) pushHistory(r)
|
||||
pruneHistory(alive)
|
||||
renderKpis(res.summary)
|
||||
renderTops(res)
|
||||
renderTable(res)
|
||||
if (state.selectedPid) {
|
||||
if (state.byPid.has(state.selectedPid)) await renderDetail(state.selectedPid)
|
||||
else {
|
||||
state.selectedPid = null
|
||||
await renderDetail(null)
|
||||
}
|
||||
}
|
||||
const age = res.ts ? `${Math.max(0, Math.round((Date.now() - res.ts) / 1000))}s` : '—'
|
||||
setStatus(
|
||||
res.supported === false
|
||||
? res.error || 'Unsupported platform'
|
||||
: `${res.totalMatched ?? res.processes?.length ?? 0} shown · ${
|
||||
res.summary?.total ?? 0
|
||||
} total · sample ${age} ago${res.chartsEnabled ? '' : ' · enable PEARDATA_PROCESSES for Charts history'}`
|
||||
)
|
||||
} catch (err) {
|
||||
if (gen !== state.gen) return
|
||||
setStatus(err?.message || 'Failed to load processes', true)
|
||||
} finally {
|
||||
if (gen === state.gen) {
|
||||
state.loading = false
|
||||
opts.els.root?.classList.remove('is-loading')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncFollowTimer() {
|
||||
if (state.timer) {
|
||||
clearInterval(state.timer)
|
||||
state.timer = null
|
||||
}
|
||||
const ms = state.follow ? REFRESH[state.refreshKey] || 0 : 0
|
||||
if (ms > 0) {
|
||||
state.timer = setInterval(() => refresh(), ms)
|
||||
}
|
||||
opts.els.followBtn?.classList.toggle('active', state.follow)
|
||||
opts.els.followBtn?.setAttribute('aria-pressed', state.follow ? 'true' : 'false')
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const rows = state.last?.processes || []
|
||||
if (!rows.length) return
|
||||
const cols = [
|
||||
'pid',
|
||||
'ppid',
|
||||
'user',
|
||||
'name',
|
||||
'stateLabel',
|
||||
'cpu',
|
||||
'mem',
|
||||
'io',
|
||||
'threads',
|
||||
'ageSec',
|
||||
'cmdline',
|
||||
]
|
||||
const lines = [cols.join(',')]
|
||||
for (const r of rows) {
|
||||
lines.push(
|
||||
cols
|
||||
.map((c) => {
|
||||
const v = r[c] ?? ''
|
||||
const s = String(v).replace(/"/g, '""')
|
||||
return `"${s}"`
|
||||
})
|
||||
.join(',')
|
||||
)
|
||||
}
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/csv' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `peardata-processes-${Date.now()}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function bind() {
|
||||
const root = opts.els.root
|
||||
if (!root || root.dataset.bound) return
|
||||
root.dataset.bound = '1'
|
||||
|
||||
opts.els.q?.addEventListener('input', () => {
|
||||
state.q = opts.els.q?.value || ''
|
||||
refresh()
|
||||
})
|
||||
opts.els.q?.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
if (opts.els.q) opts.els.q.value = ''
|
||||
state.q = ''
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
|
||||
opts.els.filter?.querySelectorAll('[data-proc-filter]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.filter = btn.getAttribute('data-proc-filter') || 'all'
|
||||
opts.els.filter?.querySelectorAll('[data-proc-filter]').forEach((b) => {
|
||||
b.classList.toggle('active', b === btn)
|
||||
})
|
||||
refresh()
|
||||
})
|
||||
})
|
||||
|
||||
opts.els.sort?.addEventListener('change', () => {
|
||||
const v = opts.els.sort?.value || 'cpu:desc'
|
||||
const [sort, order] = v.split(':')
|
||||
state.sort = sort || 'cpu'
|
||||
state.order = order === 'asc' ? 'asc' : 'desc'
|
||||
refresh()
|
||||
})
|
||||
|
||||
opts.els.refresh?.addEventListener('change', () => {
|
||||
state.refreshKey = opts.els.refresh?.value || '2s'
|
||||
syncFollowTimer()
|
||||
})
|
||||
|
||||
opts.els.followBtn?.addEventListener('click', () => {
|
||||
state.follow = !state.follow
|
||||
syncFollowTimer()
|
||||
})
|
||||
opts.els.refreshBtn?.addEventListener('click', () => refresh())
|
||||
opts.els.treeBtn?.addEventListener('click', () => {
|
||||
state.tree = !state.tree
|
||||
opts.els.treeBtn?.classList.toggle('active', state.tree)
|
||||
opts.els.treeBtn?.setAttribute('aria-pressed', state.tree ? 'true' : 'false')
|
||||
if (state.last) renderTable(state.last)
|
||||
else refresh()
|
||||
})
|
||||
opts.els.exportBtn?.addEventListener('click', () => exportCsv())
|
||||
opts.els.chartsBtn?.addEventListener('click', () => {
|
||||
opts.onOpenCharts?.('processes.top_cpu')
|
||||
})
|
||||
|
||||
opts.els.table?.querySelectorAll('th[data-sort]').forEach((th) => {
|
||||
th.addEventListener('click', () => {
|
||||
const sort = th.getAttribute('data-sort') || 'cpu'
|
||||
if (state.sort === sort) {
|
||||
state.order = state.order === 'desc' ? 'asc' : 'desc'
|
||||
} else {
|
||||
state.sort = sort
|
||||
state.order = sort === 'name' || sort === 'pid' ? 'asc' : 'desc'
|
||||
}
|
||||
if (opts.els.sort) {
|
||||
opts.els.sort.value = `${state.sort}:${state.order}`
|
||||
}
|
||||
refresh()
|
||||
})
|
||||
})
|
||||
|
||||
window.addEventListener('keydown', (ev) => {
|
||||
if (!root || root.classList.contains('hidden')) return
|
||||
const tag = (ev.target && /** @type {HTMLElement} */ (ev.target).tagName) || ''
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
||||
if (ev.key === '/') {
|
||||
ev.preventDefault()
|
||||
opts.els.q?.focus()
|
||||
} else if (ev.key === 'r' || ev.key === 'R') {
|
||||
refresh()
|
||||
} else if (ev.key === 'f' || ev.key === 'F') {
|
||||
state.follow = !state.follow
|
||||
syncFollowTimer()
|
||||
} else if (ev.key === 't' || ev.key === 'T') {
|
||||
state.tree = !state.tree
|
||||
opts.els.treeBtn?.classList.toggle('active', state.tree)
|
||||
if (state.last) renderTable(state.last)
|
||||
} else if (ev.key === 'Escape' && state.selectedPid) {
|
||||
state.selectedPid = null
|
||||
renderDetail(null)
|
||||
opts.els.tbody?.querySelectorAll('.proc-row').forEach((tr) => tr.classList.remove('is-selected'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function enter() {
|
||||
bind()
|
||||
if (opts.els.sort) opts.els.sort.value = `${state.sort}:${state.order}`
|
||||
if (opts.els.refresh) opts.els.refresh.value = state.refreshKey
|
||||
syncFollowTimer()
|
||||
refresh()
|
||||
if (!state.selectedPid) renderDetail(null)
|
||||
}
|
||||
|
||||
function leave() {
|
||||
if (state.timer) {
|
||||
clearInterval(state.timer)
|
||||
state.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enter,
|
||||
leave,
|
||||
refresh,
|
||||
getState: () => state,
|
||||
}
|
||||
}
|
||||
+482
@@ -3162,3 +3162,485 @@ html[data-theme='light'] .chart-focus-canvas-wrap {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Processes tab ─── */
|
||||
#processes-view:not(.hidden) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.proc-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.proc-header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.proc-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.proc-kpi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 50%),
|
||||
var(--bg-secondary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.proc-kpi.is-warn {
|
||||
border-color: rgba(251, 191, 36, 0.45);
|
||||
}
|
||||
|
||||
.proc-kpi-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.proc-kpi-value {
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.proc-kpi-sub {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.proc-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.proc-toolbar #proc-q {
|
||||
flex: 1 1 220px;
|
||||
min-width: 160px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.proc-toolbar #proc-q:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 45%, var(--border-color));
|
||||
}
|
||||
|
||||
.proc-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.proc-status {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.proc-status.is-error {
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
.proc-shell {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.proc-main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.proc-table-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.proc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.proc-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: color-mix(in srgb, var(--bg-elevated) 94%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.proc-table th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.proc-table th[data-sort] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.proc-table th[data-sort]:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.proc-table td {
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
vertical-align: middle;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.proc-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.proc-row:hover {
|
||||
background: color-mix(in srgb, var(--accent-primary) 6%, transparent);
|
||||
}
|
||||
|
||||
.proc-row.is-selected {
|
||||
background: color-mix(in srgb, var(--accent-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.proc-pid {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
width: 4.5rem;
|
||||
}
|
||||
|
||||
.proc-user {
|
||||
max-width: 5.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.proc-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
max-width: 28vw;
|
||||
}
|
||||
|
||||
.proc-comm {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.proc-cmd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.proc-num {
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.proc-cpu {
|
||||
min-width: 7rem;
|
||||
}
|
||||
|
||||
.proc-bar-track {
|
||||
display: inline-block;
|
||||
width: 42px;
|
||||
height: 5px;
|
||||
margin-right: 6px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.proc-bar-track i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
.proc-bar-mem i {
|
||||
background: var(--accent-info);
|
||||
}
|
||||
|
||||
.proc-pill {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.proc-pill.is-run {
|
||||
color: #34d399;
|
||||
background: rgba(52, 211, 153, 0.14);
|
||||
}
|
||||
.proc-pill.is-sleep {
|
||||
color: var(--text-muted);
|
||||
background: rgba(154, 168, 188, 0.12);
|
||||
}
|
||||
.proc-pill.is-zombie {
|
||||
color: #fbbf24;
|
||||
background: rgba(251, 191, 36, 0.16);
|
||||
}
|
||||
.proc-pill.is-stop {
|
||||
color: #fb7185;
|
||||
background: rgba(251, 113, 133, 0.14);
|
||||
}
|
||||
.proc-pill.is-disk {
|
||||
color: #38bdf8;
|
||||
background: rgba(56, 189, 248, 0.14);
|
||||
}
|
||||
|
||||
.proc-empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.proc-side {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.proc-side-card {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.proc-side-card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.proc-top-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.proc-top-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2rem 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
padding: 5px 6px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.proc-top-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.proc-top-rank {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.proc-top-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.proc-top-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.proc-detail-card {
|
||||
flex: 1 1 auto;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.proc-detail-empty {
|
||||
margin: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.proc-detail-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.proc-detail-kicker {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.proc-detail-head h3 {
|
||||
margin: 2px 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.proc-detail-user {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.proc-detail-cmd {
|
||||
margin: 10px 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
max-height: 4.2em;
|
||||
overflow: auto;
|
||||
word-break: break-all;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
html[data-theme='light'] .proc-detail-cmd {
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.proc-detail-sparks {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.proc-detail-sparks .muted {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.proc-spark {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.proc-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.proc-detail-grid div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.proc-detail-grid dt {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-faint);
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.proc-detail-grid dd {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#processes-view.is-loading .proc-table tbody {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.proc-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.proc-side {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.proc-detail-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.proc-side {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.proc-name {
|
||||
max-width: 40vw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Processes
|
||||
|
||||
The **Processes** tab is a live host process monitor backed by Linux `/proc`.
|
||||
|
||||
## What you get
|
||||
|
||||
- KPI strip: process count, aggregate CPU / RSS / threads, load, zombies
|
||||
- Sortable table with CPU / memory bars, state pills, age, I/O rates
|
||||
- Filters: Running, Hot CPU, Hot mem, I/O, Zombie, User, Kernel
|
||||
- Parent/child **Tree** view
|
||||
- Detail drawer: cmdline, cgroup, exe/cwd, FDs, ctxt switches, sparklines
|
||||
- CSV export · Follow refresh (1s / 2s / 5s) · jump to **Charts** top series
|
||||
|
||||
## Requirements
|
||||
|
||||
- Agent on **Linux** with readable `/proc`
|
||||
- Viewer role is enough for `listProcesses`
|
||||
- Historical top charts (`processes.top_*`) default **on** for Linux; set `PEARDATA_PROCESSES=0` to disable
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `/` | Focus search |
|
||||
| `R` | Refresh |
|
||||
| `F` | Toggle Follow |
|
||||
| `T` | Toggle Tree |
|
||||
| `Esc` | Clear selection |
|
||||
|
||||
## API
|
||||
|
||||
- RPC: `listProcesses`
|
||||
- REST: `GET /api/v1|v2|v3/processes?sort=cpu&filter=running&q=nginx`
|
||||
|
||||
## Related
|
||||
|
||||
- Charts family `processes` — [CONFIGURATION.md](../docs/CONFIGURATION.md)
|
||||
- Protocol — [PROTOCOL.md](../docs/PROTOCOL.md)
|
||||
Reference in New Issue
Block a user