/** * 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, * 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|null} */ timer: null, /** @type {Map} */ history: new Map(), /** @type {Map} */ byPid: new Map(), } function escapeHtml(s) { return String(s ?? '') .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) => `
${escapeHtml(c.label)} ${escapeHtml(String(c.value))} ${escapeHtml(c.sub)}
` ) .join('') } function renderTops(res) { const mk = (el, rows, key) => { if (!el) return if (!rows?.length) { el.innerHTML = '

No data yet

' return } el.innerHTML = rows .map((r, i) => { const v = key === 'cpu' ? `${Number(r.cpu).toFixed(1)}%` : formatMem(r.mem) return `` }) .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} */ 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} */ 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 ` ${r.pid} ${escapeHtml(r.user || '—')} ${escapeHtml(r.name)} ${escapeHtml( (r.cmdline || '').slice(0, 72) )} ${escapeHtml( r.stateLabel || r.state || '?' )} ${Number(r.cpu).toFixed(1)}% ${escapeHtml(formatMem(r.mem))} ${Number(r.io).toFixed(1)} ${r.threads} ${formatAge(r.ageSec)} ` }) .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 = '

Select a process to inspect

' 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 = `

PID ${pid} gone

` return } const hist = state.history.get(pid) || { cpu: [], mem: [] } panel.innerHTML = `

PID ${row.pid} · ppid ${row.ppid}

${escapeHtml(row.name)}

${escapeHtml(row.user || '—')} · nice ${row.nice} · cpu${ row.processor ?? '—' }

${escapeHtml( row.cmdline || '—' )}

CPU
RSS
State
${escapeHtml(row.stateLabel || row.state)}
CPU
${Number(row.cpu).toFixed(2)}% (u ${Number( row.cpuUser ).toFixed(1)} / s ${Number(row.cpuSystem).toFixed(1)})
RSS
${escapeHtml(formatMem(row.mem))}
VSZ
${escapeHtml(formatMem(row.vsz))}
Swap
${escapeHtml(formatMem(row.swap))}
Threads
${row.threads}
I/O
${Number(row.ioRead).toFixed(1)} / ${Number(row.ioWrite).toFixed( 1 )} KiB/s
FDs
${row.fds != null ? row.fds : '—'}
Age
${formatAge(row.ageSec)}
Cgroup
${escapeHtml( row.cgroup || '—' )}
Exe
${escapeHtml( row.exe || '—' )}
Cwd
${escapeHtml( row.cwd || '—' )}
Wchan
${escapeHtml(row.wchan || '—')}
Ctxt
${row.voluntaryCtxt ?? '—'} vol / ${ row.nonvoluntaryCtxt ?? '—' } nonvol
` 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, } }