diff --git a/Plugin/unraid/api/docker_action.php b/Plugin/unraid/api/docker_action.php index e467011..6531bc1 100644 --- a/Plugin/unraid/api/docker_action.php +++ b/Plugin/unraid/api/docker_action.php @@ -127,8 +127,15 @@ if ($check !== $name) { echo json_encode(['ok' => false, 'error' => 'Container not found']); exit; } +// Every arm below drops the monitor cache once the container state has actually changed. That +// payload carries the container list the dashboard draws, is served with a 300s window, and is +// otherwise only rewritten by the once-a-minute cache writer — so without this the operator stops +// a container, watches the card, and sees it running for up to a minute with no hint as to why. +// Cleared after the command rather than before, so a failed action does not throw away a payload +// that is still accurate. if ($action === 'start' || $action === 'stop') { exec(($action === 'start' ? 'docker start' : 'docker stop') . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc); + if ($rc === 0) vv_cache_clear('monitor'); echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]); exit; } @@ -142,6 +149,7 @@ if ($action === 'restart') { $out = array_merge($o1, $o2); $rc = ($rc1 === 0 && $rc2 === 0) ? 0 : 1; } + if ($rc === 0) vv_cache_clear('monitor'); echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]); exit; } diff --git a/Plugin/unraid/api/docker_pull_worker.php b/Plugin/unraid/api/docker_pull_worker.php index ddc7d84..b34e659 100644 --- a/Plugin/unraid/api/docker_pull_worker.php +++ b/Plugin/unraid/api/docker_pull_worker.php @@ -92,6 +92,10 @@ if (PHP_SAPI !== 'cli') { if (!$name || !$jobFile || !$image) exit(1); +// Only for vv_cache_clear() below. Required after the CLI guard, so a stray web request is turned +// away before this process loads anything at all. +require_once dirname(__DIR__) . '/include/config.php'; + function jw(string $f, array $d): void { file_put_contents($f, json_encode($d)); } shell_exec('docker pull ' . escapeshellarg($image) . ' 2>&1'); @@ -115,6 +119,10 @@ if ($rebuild && is_executable($rebuild)) { $rc = ($rc1 === 0 && $rc2 === 0) ? 0 : 1; } +// The container was stopped and started to get here whether or not the rebuild reported success, +// so the cached container list is out of date either way. +vv_cache_clear('monitor'); + jw($jobFile, $rc === 0 ? ['ok' => true, 'status' => 'done', 'updated' => true, 'message' => 'Updated and rebuilt'] : ['ok' => false, 'status' => 'done', 'error' => 'Rebuild failed after pull'] diff --git a/Plugin/unraid/include/config.php b/Plugin/unraid/include/config.php index f1c1713..ff2a28c 100644 --- a/Plugin/unraid/include/config.php +++ b/Plugin/unraid/include/config.php @@ -456,6 +456,17 @@ function vv_cache_write(string $key, array $data): void { rename($tmp, $f); } +// Drop a cached payload so the next read collects fresh. For use by endpoints that change the +// very state a cache describes: without it the UI polls a payload that cannot yet know about the +// action it just took, and the operator sees a container they stopped still running until the +// background writer next comes round. +// +// Best-effort by design. A cache that could not be removed is a stale read, which is what would +// have happened anyway — never a reason to fail the action that was actually requested. +function vv_cache_clear(string $key): void { + @unlink(VV_CACHE_DIR . '/' . $key . '.json'); +} + // ── Shared utility functions (used across include/ and api/ files) ──────────── // Format seconds into "2d 3h 15m". diff --git a/Plugin/unraid/pages/monitor.php b/Plugin/unraid/pages/monitor.php index 1b1318e..8e7ef3b 100644 --- a/Plugin/unraid/pages/monitor.php +++ b/Plugin/unraid/pages/monitor.php @@ -27,8 +27,15 @@ // the page reported healthy unconditionally (fixed 2026-08-02). If this panel looks // suspiciously green, verify the paths before believing it. // -// Container actions are confirmed and routed through the action endpoint, which validates -// against real inventory. +// Stopping a container is confirmed; starting one is not, and the endpoint validates every +// action against real inventory regardless. Failures are surfaced rather than swallowed — the +// response used to be discarded, which made a refused stop indistinguishable from a completed +// one. +// +// An action that changes container state clears the monitor cache. +// api/docker_action.php and the pull worker both drop it, so the next poll collects +// instead of re-reading a payload written before the action. Without that the card +// contradicted the button for up to a minute. // // All remote and container-supplied strings render escaped. // Through vvEscHtml()/vvEscAttr() from Varaverk.page — media titles and usernames from @@ -2249,13 +2256,27 @@ function vvDockerAction(action, name, webui) { encodeURIComponent('/boot/config/plugins/dockerMan/templates-user/my-' + name + '.xml') + '&update=true'; return; } + // Stopping is confirmed; starting is not. The asymmetry is the point — start is recoverable by + // clicking the other button, stop takes a service away from whoever is using it, and these + // buttons sit inside a dense grid where the row under the cursor is easy to misjudge. + if (action === 'stop' && !confirm('Stop ' + name + '?')) return; + const fd = new URLSearchParams(); fd.set('action', action); fd.set('name', name); fetch('/plugins/varaverk/api/docker_action.php', { method: 'POST', body: fd }) .then(r => r.json()) - .then(() => { vvDfActive = null; setTimeout(vvPollMonitor, 1500); }) - .catch(() => {}); + .then(d => { + // The endpoint reports refusals as ok:false with a reason — container not found, a non-zero + // docker exit. Discarding that made a failed stop look exactly like a successful one, since + // 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); + }) + .catch(() => alert('Container ' + action + ' failed: request error')); } function vvRenderDockerFolders(data) {