diff --git a/Plugin/unraid/api/system.php b/Plugin/unraid/api/system.php index df40207..ea1c4ff 100644 --- a/Plugin/unraid/api/system.php +++ b/Plugin/unraid/api/system.php @@ -5,6 +5,13 @@ // most destructive operations the plugin can perform, deliberately isolated in one small // file rather than folded into a general-purpose action endpoint. // +// STATUS +// No UI caller. pages/monitor.php held the only one — a vvArrayAction() that no button on any +// tab invoked — and it was removed 2026-08-07 rather than left as an unreachable handler for +// these three commands. The endpoint is kept because it is complete and correct, and because +// power control is a thing this plugin will plausibly want; wiring it up is adding buttons, not +// writing an endpoint. Anything added here must keep the guarantees below intact. +// // OPERATIONAL MODEL // The command is detached and the response returns immediately. A shutdown kills the web // server that is serving this request, so waiting on the child would mean the browser sees diff --git a/Plugin/unraid/pages/monitor.php b/Plugin/unraid/pages/monitor.php index 8e7ef3b..3e4aa4c 100644 --- a/Plugin/unraid/pages/monitor.php +++ b/Plugin/unraid/pages/monitor.php @@ -7,14 +7,25 @@ // // DESIGN PRINCIPLES // Two poll rates, deliberately split. -// api/monitor_fast.php carries the cheap, fast-moving values (1–2s); api/monitor.php -// carries the full payload on a slower cycle. Everything refreshing at the fast rate -// would put real load on the WebGUI this page exists to watch. +// api/monitor_fast.php carries the cheap, fast-moving values at 1s; api/monitor.php +// carries the full payload at 5s. Everything refreshing at the fast rate would put real +// load on the WebGUI this page exists to watch. // // Served from the tmpfs cache, not live calls. // api_cache_writer.sh refreshes the payload every minute and the endpoint serves that. -// ?live=1 bypasses it. A missing cache always falls back to a live call, so the cache -// can never be why the dashboard fails to load. +// ?live=1 bypasses it, and is used for exactly one thing: the poll that follows a +// container action, where the point is to see the result. A missing cache always falls +// back to a live call, so the cache can never be why the dashboard fails to load. +// +// This principle was written before it was true. The page sent ?live=1 on every poll but +// the first, so it paid a full collection — partner SSH timeouts included, per that +// endpoint's own warning — every two seconds, and the cache it describes was used once +// per page load. Fixed 2026-08-07. If this page ever feels heavy, check here first. +// +// Polls are guarded, not merely scheduled. +// vvPollRunner() drops a tick while the previous request is still open and stops +// entirely while the tab is hidden. Both polls ran unconditionally before, so a slow +// collection stacked requests behind itself and a background tab polled forever. // // Missing subsystems simply do not render. // No GPU, no UPS, no VMs — the corresponding card is absent rather than showing zeros @@ -57,8 +68,7 @@ // DEPENDS ON // include/monitor.php required directly for initial render // api/monitor.php full payload, slower cycle -// api/monitor_fast.php fast-moving values, 1–2s -// api/system.php system info +// api/monitor_fast.php fast-moving values, 1s // api/media.php now-playing sessions // api/docker_action.php container actions // api/flag_toggle.php toggles @@ -412,14 +422,6 @@ function vvDrawNetChart(canvas, rxData, txData, maxBps) { // ── CPU helpers ─────────────────────────────────────────────────────────────── -function vvCoreColor(freqMhz, maxMhz, minMhz) { - if (!freqMhz || !maxMhz || maxMhz === minMhz) return '#4caf50'; - const t = Math.max(0, Math.min(1, (freqMhz - minMhz) / (maxMhz - minMhz))); - // blue(220°) → green(120°) → red(0°) as t goes 0→1 - const hue = Math.round((1 - t) * 220); - return `hsl(${hue},70%,45%)`; -} - function vvRenderCpu(cpu) { const overall = cpu.overall ?? 0; const cores = cpu.cores ?? []; @@ -697,14 +699,54 @@ function vvDiskCol(disks) { // ── Poll ────────────────────────────────────────────────────────────────────── -let _vvFirstPoll = true; +// Runs fn on an interval under three rules, because a dashboard that polls harder than its data +// changes is load on the machine it exists to watch: +// +// in-flight a tick arriving while the previous request is still open is dropped rather than +// queued. A slow collection used to stack requests behind itself at 1–2s intervals, +// and the slow case is precisely the loaded one. +// hidden nothing polls while the tab is not visible. This page ran 1s and 2s timers +// forever in a background tab. +// resume one immediate tick when the tab comes back, so returning to it does not show a +// frozen dashboard for a full interval. +// +// fn must return the fetch promise, or the in-flight flag can never clear. +function vvPollRunner(fn, ms) { + let busy = false; + const tick = () => { + if (busy || document.hidden) return; + busy = true; + Promise.resolve(fn()).catch(() => {}).finally(() => { busy = false; }); + }; + tick(); + setInterval(tick, ms); + document.addEventListener('visibilitychange', () => { if (!document.hidden) tick(); }); +} -function vvPollMonitor() { - const _url = _vvFirstPoll ? '/plugins/varaverk/api/monitor.php' : '/plugins/varaverk/api/monitor.php?live=1'; - _vvFirstPoll = false; - fetch(_url) +// Consecutive poll failures. A dashboard whose endpoint has died looks exactly like one where +// nothing is happening, which is the worst way for it to fail — every number on screen stays at +// its last good value and nothing says so. Three in a row rather than one, so a single blip +// during a restart does not throw a banner. +let vvPollFails = 0; +function vvPollFailed() { + if (++vvPollFails < 3) return; + const banner = document.getElementById('vv-api-banner'); + if (!banner) return; + Object.assign(banner.style, {display:'', background:'#1a0d0d', border:'1px solid #4a1f1f', color:'#ef5350'}); + banner.textContent = 'Monitor data is not updating — ' + vvPollFails + + ' consecutive failed polls. Values below are the last good reading.'; +} + +// live=1 bypasses the endpoint's cache and collects everything fresh. Reserved for the poll that +// follows an action, where the cache has just been dropped and the point is to see the result. +// Every ordinary poll reads the cache, which is what the endpoint was built for and what its own +// design principles describe — this used to send live=1 on every poll after the first, so the +// page paid a full collection, partner SSH timeouts included, every two seconds. +function vvPollMonitor(live) { + return fetch('/plugins/varaverk/api/monitor.php' + (live ? '?live=1' : '')) .then(r => r.json()) .then(d => { + vvPollFails = 0; // ── API status banner ──────────────────────────────────────────────────── const apiStatus = d._api_status ?? {}; @@ -1776,15 +1818,16 @@ function vvPollMonitor() { vvRenderDockerFolders(dfData); }) - .catch(() => {}); + .catch(vvPollFailed); } -vvPollMonitor(); -setInterval(vvPollMonitor, 2000); +// 5s against a payload the cache writer refreshes once a minute. Polling faster cannot make the +// data newer — it only decides how soon the page notices the writer's update. +vvPollRunner(vvPollMonitor, 5000); // ── Fast poll: CPU, memory, network — 1-second live updates ────────────────── function vvPollFast() { - fetch('/plugins/varaverk/api/monitor_fast.php') + return fetch('/plugins/varaverk/api/monitor_fast.php') .then(r => r.json()) .then(d => { // CPU @@ -1842,8 +1885,9 @@ function vvPollFast() { .catch(() => {}); } -vvPollFast(); -setInterval(vvPollFast, 1000); +// Stays at 1s — this endpoint reads /proc and borrows its slow fields from the full cache, so it +// is cheap enough to be the one thing that genuinely updates live. +vvPollRunner(vvPollFast, 1000); // Pin pools card width to CPU card width across rows function vvSyncCardWidths() { @@ -2064,7 +2108,7 @@ function vvRenderStreams() { } function vvPollStreams() { - fetch('/plugins/varaverk/api/media.php') + return fetch('/plugins/varaverk/api/media.php') .then(r => r.json()) .then(d => { vvLastSessions = d.sessions ?? []; @@ -2083,8 +2127,11 @@ function vvPollStreams() { .catch(() => {}); } -vvPollStreams(); -setInterval(vvPollStreams, 12000); +// Guarded like the others — this one reaches out to every configured media server, so a wedged +// Emby is exactly the case where unguarded ticks would stack. +vvPollRunner(vvPollStreams, 12000); +// Local only: re-renders the rows already held, advancing each progress bar between polls. No +// request, so it stays a plain interval. setInterval(vvRenderStreams, 1000); // ── Pools card ──────────────────────────────────────────────────────────────── @@ -2272,9 +2319,10 @@ function vvDockerAction(action, name, webui) { // the card it would have changed is redrawn from a payload either way. if (!d || !d.ok) alert('Container ' + action + ' failed: ' + ((d && (d.error || d.output)) || 'unknown error')); vvDfActive = null; - // The endpoint drops the monitor cache on success, so this poll collects fresh rather than - // re-reading the payload that was written before the action happened. - setTimeout(vvPollMonitor, 1500); + // The endpoint drops the monitor cache on success, and this poll asks for a live collection + // besides — either alone is enough, but between them the card cannot redraw itself from a + // payload assembled before the action happened. + setTimeout(() => vvPollMonitor(true), 1500); }) .catch(() => alert('Container ' + action + ' failed: request error')); } @@ -2429,16 +2477,8 @@ document.addEventListener('click', () => { if (vvDfActive !== null) { vvDfActive = null; vvRenderDockerFolders(vvDfData); } }); -// ── Array actions ───────────────────────────────────────────────────────────── -function vvArrayAction(action) { - const labels = {stop: 'Stop Array', shutdown: 'Shutdown', restart: 'Restart'}; - if (!confirm(`${labels[action] ?? action} — are you sure?`)) return; - fetch('/plugins/varaverk/api/system.php', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({action}), - }).then(r => r.json()).then(d => { - if (!d.ok) alert('Error: ' + (d.error ?? 'unknown')); - }).catch(() => alert('Request failed')); -} +// Array power actions (stop / shutdown / restart) were wired here to api/system.php, but nothing +// on this page ever called the function — there are no such buttons, on this or any other tab. +// Removed rather than left as an unreachable handler for the platform's three most destructive +// operations. The endpoint stays; see its header for why it is kept unwired.