664 lines
21 KiB
JavaScript
664 lines
21 KiB
JavaScript
/**
|
|
* 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,
|
|
}
|
|
}
|