]> ###2026.06.28 - Native Dashboard tile listing the busiest CPU/RAM processes. - Twin flat CPU+RAM bars, sort by CPU/MEM, native refresh selector. - Accurate %CPU from /proc incl. kernel threads; overall CPU + io-wait readout. - Low-cost stateful endpoint; polls only while visible. Settings: rows/sort/interval/kernel-threads. ]]> =')" Menu="Dashboard:0" --- ]]> 8, 'SORT' => 'cpu', 'INTERVAL' => 5]; $topn = (int) ($cfg['TOPN'] ?? 8); $sort = (($cfg['SORT'] ?? 'cpu') === 'mem') ? 'mem' : 'cpu'; $intv = (int) ($cfg['INTERVAL'] ?? 5); $kthr = (($cfg['KTHREADS'] ?? '1') !== '0') ? '1' : '0'; ?>
_(Number of processes shown)_: : _(Default sort)_: : _(Refresh interval)_: : _(Show kernel threads)_: :   :
> _(These are the defaults applied when the Dashboard tile first loads. The CPU/MEM > toggle and interval selector on the tile itself override them for the current view.)_ ]]>
with TWO rows: the header row and the data * row. Unraid's native "Show/Hide content" chevron hides tr:gt(0), so the data * row collapses like every native tile (md5-keyed state persists). Process rows * are plain
s — NO nested / (that breaks addProperties()). * CSS/JS are inlined; strings go through _() when available (i18n, safe to * interpolate — it just returns a string). */ global $mytiles; $base = '/usr/local/emhttp/plugins/topprocesses'; $cfg = @parse_ini_file('/boot/config/plugins/topprocesses/topprocesses.cfg') ?: []; $sort = (($cfg['SORT'] ?? 'cpu') === 'mem') ? 'mem' : 'cpu'; $interval = (int) ($cfg['INTERVAL'] ?? 5); $css = (string) @file_get_contents("$base/styles/topprocesses.css"); $js = (string) @file_get_contents("$base/javascript/topprocesses.js"); $tr = function_exists('_'); $t_title = $tr ? _('Top Processes') : 'Top Processes'; $t_hint = $tr ? _('Top processes by CPU/MEM. 100% = one full core (htop-style).') : 'Top processes by CPU/MEM. 100% = one full core (htop-style).'; $t_sort = $tr ? _('Sort by CPU or memory') : 'Sort by CPU or memory'; $t_refresh = $tr ? _('Refresh interval') : 'Refresh interval'; $t_set = $tr ? _('Settings') : 'Settings'; $t_load = $tr ? _('loading…') : 'loading…'; $intOpts = ''; foreach (['2' => '2s', '5' => '5s', '10' => '10s', '0' => 'off'] as $v => $lab) { $sel = ($interval === (int) $v) ? ' selected' : ''; $intOpts .= ""; } $mytiles['topprocesses']['column1'] = << EOT; ]]> /stat (no extra statm read). The same tmpfs file also caches the * rendered JSON for ~1s so concurrent dashboard viewers share one sampler. * A deliberate request-driven design — no background daemon (which would burn * CPU 24/7 even when nobody is looking). Read-only. PHP 8.3/8.4 clean. */ header('Content-Type: application/json'); header('Cache-Control: no-store'); $cfg = @parse_ini_file('/boot/config/plugins/topprocesses/topprocesses.cfg') ?: []; $topN = (int) ($cfg['TOPN'] ?? 8); if ($topN < 1) { $topN = 8; } if ($topN > 50) { $topN = 50; } // Show kernel threads by default — on Unraid the CPU is often pegged by kernel // threads (ZFS z_*/txg_sync, md parity, kcompactd, kworker), so hiding them // would hide the real culprit. Set KTHREADS="0" to show only userspace. $showKt = (($cfg['KTHREADS'] ?? '1') !== '0'); $CACHE = "/dev/shm/topprocesses-$topN-" . ($showKt ? 'k' : 'u') . ".json"; $RESULT_TTL = 1.0; // serve the rendered JSON without resampling within this window $SNAP_MAXAGE = 15.0; // older snapshot -> treat as cold start (brief instantaneous sample) $now = microtime(true); $state = null; $raw = @file_get_contents($CACHE); if ($raw !== false && $raw !== '') { $d = json_decode($raw, true); if (is_array($d)) { $state = $d; } } /* still-fresh rendered JSON: return it untouched (no sampling) */ if ($state && isset($state['ts'], $state['json']) && ($now - $state['ts']) < $RESULT_TTL) { echo $state['json']; exit; } /* aggregate cpu jiffies (excl. guest/guest_nice) + cpu count + idle + iowait */ function cpu_snapshot(): array { $total = 0; $ncpu = 0; $idle = 0; $iowait = 0; foreach (explode("\n", (string) @file_get_contents('/proc/stat')) as $l) { if (strncmp($l, 'cpu', 3) !== 0) { break; } // cpu* lines come first if (isset($l[3]) && $l[3] === ' ') { // aggregate "cpu " line $f = preg_split('/\s+/', trim($l)); array_shift($f); $n = min(8, count($f)); for ($i = 0; $i < $n; $i++) { $total += (int) $f[$i]; } $idle = (int) ($f[3] ?? 0); // idle $iowait = (int) ($f[4] ?? 0); // iowait } else { $ncpu++; // cpuN } } return [$total, max(1, $ncpu), $idle, $iowait]; } /* one /proc walk -> pid => [comm, j(=utime+stime), rss(KiB)] */ function walk_procs(bool $showKt): array { $out = []; $dh = @opendir('/proc'); if (!$dh) { return $out; } while (($d = readdir($dh)) !== false) { if (!ctype_digit($d)) { continue; } $stat = @file_get_contents("/proc/$d/stat"); if ($stat === false) { continue; } $rp = strrpos($stat, ')'); if ($rp === false) { continue; } $rest = explode(' ', trim(substr($stat, $rp + 1))); // idx0=state(f3); flags(f9)=6; utime(f14)=11; stime(f15)=12; rss(f24)=21 if (!isset($rest[21])) { continue; } if (!$showKt && (((int) $rest[6]) & 0x00200000)) { continue; } // PF_KTHREAD $lp = strpos($stat, '('); $comm = ($lp !== false && $rp > $lp) ? substr($stat, $lp + 1, $rp - $lp - 1) : '?'; $out[$d] = ['comm' => $comm, 'j' => (int) $rest[11] + (int) $rest[12], 'rss' => (int) $rest[21] * 4]; } closedir($dh); return $out; } [$total1, $ncpu, $idle1, $iowait1] = cpu_snapshot(); $cur = walk_procs($showKt); $cpu = []; $haveSnap = $state && isset($state['per'], $state['total'], $state['ts']) && ($now - $state['ts']) > 0.2 && ($now - $state['ts']) < $SNAP_MAXAGE; if ($haveSnap) { $dtotal = max(1, $total1 - (int) $state['total']); $didle = $idle1 - (int) ($state['idle'] ?? $idle1); $diowait = $iowait1 - (int) ($state['iowait'] ?? $iowait1); $prev = $state['per']; foreach ($cur as $pid => $info) { $p0 = isset($prev[$pid]) ? (int) $prev[$pid] : $info['j']; // new pid -> 0 this round $c = 100.0 * ($info['j'] - $p0) / $dtotal * $ncpu; $cpu[$pid] = $c > 0 ? round($c, 1) : 0.0; } } else { usleep(150000); // cold start only: one short instantaneous sample [$total2, , $idle2, $iowait2] = cpu_snapshot(); $cur2 = walk_procs($showKt); $dtotal = max(1, $total2 - $total1); $didle = $idle2 - $idle1; $diowait = $iowait2 - $iowait1; foreach ($cur2 as $pid => $info) { $p0 = isset($cur[$pid]) ? $cur[$pid]['j'] : $info['j']; $c = 100.0 * ($info['j'] - $p0) / $dtotal * $ncpu; $cpu[$pid] = $c > 0 ? round($c, 1) : 0.0; } $cur = $cur2; $total1 = $total2; $idle1 = $idle2; $iowait1 = $iowait2; } /* overall CPU context (matches the Processor tile's "Overall Load") so the list * reconciles with it: busy includes iowait; iowait is surfaced separately * because it belongs to no process. */ $loadBusy = (int) round(100.0 * max(0, $dtotal - $didle) / $dtotal); $loadIo = (int) round(100.0 * max(0, $diowait) / $dtotal); $memTotalKb = (int) ($state['mem'] ?? 0); if ($memTotalKb <= 0) { preg_match('/MemTotal:\s+(\d+)/', (string) @file_get_contents('/proc/meminfo'), $mt); $memTotalKb = max(1, (int) ($mt[1] ?? 0)); } $rows = []; foreach ($cur as $pid => $info) { $rows[$pid] = ['pid' => (int) $pid, 'cmd' => $info['comm'], 'cpu' => $cpu[$pid] ?? 0.0, 'rss' => $info['rss']]; } $byCpu = $rows; uasort($byCpu, fn($a, $b) => $b['cpu'] <=> $a['cpu']); $byMem = $rows; uasort($byMem, fn($a, $b) => $b['rss'] <=> $a['rss']); $topCpu = array_slice($byCpu, 0, $topN, true); $topMem = array_slice($byMem, 0, $topN, true); $userCache = []; $resolveUser = function (int $pid) use (&$userCache): string { $uid = @fileowner("/proc/$pid"); if ($uid === false) { return '?'; } if (isset($userCache[$uid])) { return $userCache[$uid]; } $name = (string) $uid; if (function_exists('posix_getpwuid')) { $pw = posix_getpwuid($uid); if (is_array($pw) && isset($pw['name'])) { $name = $pw['name']; } } return $userCache[$uid] = $name; }; $fullCmd = function (int $pid, string $fallback): string { $cl = @file_get_contents("/proc/$pid/cmdline"); if ($cl === false || $cl === '') { return $fallback; } $cl = trim(str_replace("\0", ' ', $cl)); if ($cl === '') { return $fallback; } if (strlen($cl) > 256) { $cl = substr($cl, 0, 255) . '…'; } return $cl; }; $enriched = []; foreach (($topCpu + $topMem) as $pid => $r) { // union by pid key, enrich once $r['user'] = $resolveUser((int) $pid); $r['full'] = $fullCmd((int) $pid, $r['cmd']); $r['mem'] = round(100.0 * $r['rss'] / $memTotalKb, 1); $enriched[$pid] = $r; } $pick = fn($set) => array_values(array_map(fn($r) => $enriched[$r['pid']], $set)); $json = json_encode([ 'total' => count($rows), 'load' => ['busy' => $loadBusy, 'io' => $loadIo], 'cpu' => $pick($topCpu), 'mem' => $pick($topMem), ]); echo $json; /* persist snapshot (per-pid jiffies for the next delta) + rendered JSON, atomically */ $per = []; foreach ($cur as $pid => $info) { $per[$pid] = $info['j']; } $blob = json_encode(['ts' => $now, 'total' => $total1, 'idle' => $idle1, 'iowait' => $iowait1, 'ncpu' => $ncpu, 'mem' => $memTotalKb, 'per' => $per, 'json' => $json]); $tmp = $CACHE . '.' . getmypid(); if (@file_put_contents($tmp, $blob) !== false) { @rename($tmp, $CACHE); } else { @unlink($tmp); } ]]> . Polls ONLY * while the tile is tab-visible AND on-screen/expanded (visibilitychange + * IntersectionObserver); self-heals if the tile is removed. Vanilla JS, no * deps, no dollar signs (heredoc-safe). */ (function () { 'use strict'; var tile = document.getElementById('tp_tile'); if (!tile || tile.dataset.tpInit) { return; } tile.dataset.tpInit = '1'; var ENDPOINT = '/plugins/topprocesses/include/getprocs.php'; var STORE = 'tp_view_' + location.host; var ALLOWED = [2, 5, 10, 0]; var sort = tile.dataset.sort === 'mem' ? 'mem' : 'cpu'; var interval = parseInt(tile.dataset.interval || '5', 10); if (isNaN(interval)) { interval = 5; } try { var saved = JSON.parse(localStorage.getItem(STORE) || '{}'); if (saved.sort === 'cpu' || saved.sort === 'mem') { sort = saved.sort; } if (typeof saved.interval === 'number' && ALLOWED.indexOf(saved.interval) >= 0) { interval = saved.interval; } } catch (e) {} if (ALLOWED.indexOf(interval) < 0) { interval = 5; } var timer = null, busy = false, lastTotal = 0, lastData = null, box = null; var tabVis = !document.hidden, tileVis = true, io = null; var nodes = Object.create(null); function save() { try { localStorage.setItem(STORE, JSON.stringify({ sort: sort, interval: interval })); } catch (e) {} } function num(x) { x = Number(x); return isFinite(x) ? x : 0; } function level(p) { if (p >= 90) { return 'crit'; } if (p >= 60) { return 'warn'; } return 'ok'; } function fmtPct(v) { return (v >= 10) ? Math.round(v) + '%' : v.toFixed(1) + '%'; } function fmtKb(kb) { kb = num(kb); if (kb >= 1048576) { return (kb / 1048576).toFixed(1) + 'G'; } if (kb >= 1024) { return Math.round(kb / 1024) + 'M'; } return kb + 'K'; } function setText(el, v) { v = String(v == null ? '' : v); if (el.textContent !== v) { el.textContent = v; } } function rowsBox() { if (!box) { box = document.getElementById('tp_rows'); } return box; } function metricLine(tagText) { var line = document.createElement('div'); line.className = 'tp-metric'; var tag = document.createElement('span'); tag.className = 'tp-tag'; tag.textContent = tagText; var bar = document.createElement('span'); bar.className = 'tp-bar'; bar.setAttribute('aria-hidden', 'true'); var fill = document.createElement('span'); fill.className = 'tp-fill'; bar.appendChild(fill); var pct = document.createElement('span'); pct.className = 'tp-pct'; var sec = document.createElement('span'); sec.className = 'tp-sec'; line.appendChild(tag); line.appendChild(bar); line.appendChild(pct); line.appendChild(sec); return { line: line, fill: fill, pct: pct, sec: sec }; } function makeRow() { var row = document.createElement('div'); row.className = 'tp-row'; var head = document.createElement('div'); head.className = 'tp-head'; var name = document.createElement('span'); name.className = 'tp-name'; var user = document.createElement('span'); user.className = 'tp-user'; var pid = document.createElement('span'); pid.className = 'tp-pid'; head.appendChild(name); head.appendChild(user); head.appendChild(pid); var cpu = metricLine('CPU'); // cpu.sec stays empty — alignment spacer only var mem = metricLine('MEM'); row.appendChild(head); row.appendChild(cpu.line); row.appendChild(mem.line); return { row: row, name: name, user: user, pid: pid, cpuFill: cpu.fill, cpuPct: cpu.pct, memFill: mem.fill, memPct: mem.pct, memSec: mem.sec }; } /* drive one rail from its own value: width, severity colour on fill + % */ function setRail(fill, pct, val) { var lv = level(val); var w = Math.max(0, Math.min(100, val)) + '%'; if (fill.style.width !== w) { fill.style.width = w; } var fc = 'tp-fill tp-' + lv + (val <= 0 ? ' tp-zero' : ''); if (fill.className !== fc) { fill.className = fc; } setText(pct, fmtPct(val)); var pc = 'tp-pct tp-' + lv; if (pct.className !== pc) { pct.className = pc; } } function updateRow(n, p, isTop) { setText(n.name, p.cmd); var title = p.full || p.cmd || ''; if (n.name.title !== title) { n.name.title = title; } setText(n.user, p.user); setText(n.pid, p.pid); setRail(n.cpuFill, n.cpuPct, num(p.cpu)); setRail(n.memFill, n.memPct, num(p.mem)); setText(n.memSec, fmtKb(p.rss)); n.row.classList.toggle('tp-top', !!isTop); } function render() { var b = rowsBox(); if (!b) { return; } var list = (lastData && lastData[sort]) ? lastData[sort] : []; if (!list.length) { b.textContent = ''; var e = document.createElement('div'); e.className = 'tp-empty'; e.textContent = 'No active processes'; b.appendChild(e); for (var k in nodes) { delete nodes[k]; } return; } var empty = b.querySelector('.tp-empty'); if (empty) { b.removeChild(empty); } var maxI = 0, maxV = -1; for (var i = 0; i < list.length; i++) { var v = (sort === 'mem') ? num(list[i].mem) : num(list[i].cpu); if (v > maxV) { maxV = v; maxI = i; } } var seen = Object.create(null), prev = null; for (i = 0; i < list.length; i++) { var p = list[i], key = String(p.pid); seen[key] = 1; var n = nodes[key]; if (!n) { n = makeRow(); nodes[key] = n; } updateRow(n, p, i === maxI); var ref = prev ? prev.nextSibling : b.firstChild; if (ref !== n.row) { b.insertBefore(n.row, ref); } prev = n.row; } for (var key2 in nodes) { if (!seen[key2]) { if (nodes[key2].row.parentNode) { nodes[key2].row.parentNode.removeChild(nodes[key2].row); } delete nodes[key2]; } } } function updateSubtitle() { var s = document.getElementById('tp_subtitle'); if (!s) { return; } var txt = lastTotal ? (lastTotal + ' processes') : ''; var load = lastData && lastData.load; if (load && typeof load.busy === 'number') { txt += (txt ? ' · ' : '') + 'CPU ' + load.busy + '%'; if (load.io >= 5) { txt += ' · io-wait ' + load.io + '%'; } // explains the gap vs Overall Load } setText(s, txt); } function setStale(on) { var b = rowsBox(); if (b) { b.classList.toggle('tp-stale', !!on); } } function poll() { if (!document.body.contains(tile)) { teardown(); return; } if (busy) { return; } busy = true; var ctrl = (typeof AbortController !== 'undefined') ? new AbortController() : null; var to = ctrl ? setTimeout(function () { ctrl.abort(); }, Math.min(Math.max((interval || 5) * 1000 - 250, 3000), 10000)) : null; fetch(ENDPOINT, { cache: 'no-store', credentials: 'same-origin', signal: ctrl ? ctrl.signal : undefined }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (d) { if (typeof d.total === 'number') { lastTotal = d.total; } lastData = d; render(); updateSubtitle(); setStale(false); } else { setStale(true); } }) .catch(function () { setStale(true); }) .then(function () { if (to) { clearTimeout(to); } busy = false; }); } function shouldRun() { return tabVis && tileVis && interval > 0; } function reschedule() { if (timer) { clearInterval(timer); timer = null; } if (shouldRun()) { poll(); timer = setInterval(poll, interval * 1000); } } function syncToggle() { var btns = tile.querySelectorAll('#tp_sort button'); for (var i = 0; i < btns.length; i++) { var on = btns[i].dataset.k === sort; btns[i].classList.toggle('tp-on', on); btns[i].setAttribute('aria-pressed', on ? 'true' : 'false'); } } function onSortClick(k) { sort = (k === 'mem') ? 'mem' : 'cpu'; syncToggle(); render(); updateSubtitle(); save(); poll(); } function onVis() { tabVis = !document.hidden; reschedule(); } function teardown() { if (timer) { clearInterval(timer); timer = null; } document.removeEventListener('visibilitychange', onVis); if (io) { try { io.disconnect(); } catch (e) {} } } var btns = tile.querySelectorAll('#tp_sort button'); for (var i = 0; i < btns.length; i++) { (function (b) { b.addEventListener('click', function () { onSortClick(b.dataset.k); }); })(btns[i]); } syncToggle(); var sel = document.getElementById('tp_int'); if (sel) { sel.value = String(interval); sel.addEventListener('change', function () { interval = parseInt(sel.value, 10); if (isNaN(interval)) { interval = 0; } save(); reschedule(); }); } if (typeof IntersectionObserver !== 'undefined') { io = new IntersectionObserver(function (entries) { var vis = !!(entries[0] && entries[0].isIntersecting); if (vis !== tileVis) { tileVis = vis; reschedule(); } }); var rb = rowsBox(); if (rb) { io.observe(rb); } } document.addEventListener('visibilitychange', onVis); reschedule(); })(); ]]> empty track */ /* threshold colours — computed PER RAIL from its own value, identical ramp */ .tp-fill.tp-warn { background: var(--orange-500, #e0922e); } .tp-fill.tp-crit { background: var(--red-500, #e23232); } /* per-rail % readout */ .tp-pct { flex: 0 0 auto; width: 2.6rem; text-align: right; font-size: .95rem; font-variant-numeric: tabular-nums; } .tp-pct.tp-warn { color: var(--orange-700, #c87f12); } .tp-pct.tp-crit { color: var(--red-600, #c41e1e); } /* RSS column — present (empty) on the CPU line too, so both rails align */ .tp-sec { flex: 0 0 auto; width: 2.7rem; text-align: right; opacity: .5; font-size: .9rem; font-variant-numeric: tabular-nums; } .tp-empty { opacity: .6; text-align: center; font-style: italic; padding: 6px; } @media (prefers-reduced-motion: reduce) { .tp-fill, .tp-rows { transition: none; } } ]]> mkdir -p &plugin; [ -f &plugin;/&name;.cfg ] || cp &emhttp;/default.cfg &plugin;/&name;.cfg echo "" echo "Top Processes installed. Open the Dashboard to see the tile." rm -rf &emhttp; &plugin;

$t_title

$t_load