diff --git a/package/include/docker-state.php b/package/include/docker-state.php index 7d90115..3771450 100644 --- a/package/include/docker-state.php +++ b/package/include/docker-state.php @@ -27,13 +27,33 @@ require_once MODERNUI_DOCKER_CLIENT; // populates $dockerManPaths + $driver } -function modernui_docker_state(bool $withStats = false): array +// True when getAllInfo() returned no rows while the docker daemon still lists +// containers — i.e. the stock update_container flow is mid-rewrite of the +// webui-info docker.json that getAllInfo() reads. getDockerContainers() hits +// the docker socket directly and is unaffected by that rewrite, so it's the +// source of truth: an empty info list alongside a non-empty daemon means +// "transient rewrite", not "genuinely empty server". The caller responds 503 +// for the transient case so the client keeps its current state and retries, +// instead of blanking the page on a momentarily-empty snapshot. +function modernui_is_transient_empty(int $infoCount, int $daemonCount): bool +{ + return $infoCount === 0 && $daemonCount > 0; +} + +// $transient is set true (by-ref) when the snapshot is a transient mid-rewrite +// empty (see modernui_is_transient_empty). On that path the returned array is +// empty and the caller should respond 503 rather than serve it. +function modernui_docker_state(bool $withStats = false, bool &$transient = false): array { global $dockerManPaths, $driver; // bound from DockerClient.php's top-level scope + $transient = false; + modernui_maybe_migrate_folder_view2(); $containers = []; + $infoCount = 0; + $daemonCount = 0; if (class_exists('DockerTemplates') && class_exists('DockerClient')) { try { // Two data sources to merge: @@ -41,7 +61,8 @@ function modernui_docker_state(bool $withStats = false): array // getDockerContainers() → docker socket data (Image, Status, Id, Ports, NetworkMode) // Keyed by container Name in both. $templates = new DockerTemplates(); - $info = $templates->getAllInfo(false, true, false); + $info = (array)$templates->getAllInfo(false, true, false); + $infoCount = count($info); $client = new DockerClient(); $rawContainers = $client->getDockerContainers(); @@ -51,6 +72,7 @@ function modernui_docker_state(bool $withStats = false): array $byName[$ct['Name']] = $ct; } } + $daemonCount = count($byName); // Sizes + live CPU/RAM are both expensive (sizes walks each // container's RW layer; `docker stats --no-stream` blocks ~1s for @@ -61,7 +83,7 @@ function modernui_docker_state(bool $withStats = false): array $sizes = $withStats ? modernui_fetch_sizes($client) : []; $stats = $withStats ? modernui_fetch_docker_stats() : []; - foreach ((array)$info as $name => $row) { + foreach ($info as $name => $row) { $container = $byName[$name] ?? []; $shortId = substr((string)($container['Id'] ?? ''), 0, 12); $vdisk = $sizes[$shortId] ?? null; @@ -74,6 +96,15 @@ function modernui_docker_state(bool $withStats = false): array } } + // Mid-rewrite of the webui-info docker.json: bail before reading folders/tags + // and let the caller serve a 503. The client treats that as "keep current + // state, retry on the next resync" rather than accepting an empty list. A + // genuinely empty server (daemon also empty) falls through and returns []. + if (modernui_is_transient_empty($infoCount, $daemonCount)) { + $transient = true; + return []; + } + $folders = modernui_read_folders(); $tags = modernui_read_tags(); @@ -269,5 +300,16 @@ function modernui_normalize_ports(array $rawPorts, string $url): array // ?stats=1 opts into the expensive size + docker-stats fetches. The // front-end only sends it when the Stats pill is on. $withStats = isset($_GET['stats']) && $_GET['stats'] === '1'; - modernui_json_response(modernui_docker_state($withStats)); + $transient = false; + $snapshot = modernui_docker_state($withStats, $transient); + if ($transient) { + // Mid-rewrite of the webui-info docker.json by the stock update flow: + // the container list is momentarily empty though the daemon still has + // them. 503 tells the client to keep its current state and retry — + // fetchSnapshot() throws on the non-200 and resync()'s catch swallows + // it. See modernui_is_transient_empty. + modernui_json_response(['error' => 'transient', 'retry' => true], 503); + } else { + modernui_json_response($snapshot); + } } diff --git a/src/ts/docker/boot.ts b/src/ts/docker/boot.ts index 65cf004..2345987 100644 --- a/src/ts/docker/boot.ts +++ b/src/ts/docker/boot.ts @@ -147,6 +147,13 @@ export async function boot(): Promise { // Only request stats when the toggle is on — saves a second+ on // every fetch when stats aren't shown. nchan deltas keep CPU/RAM // fresh for running containers regardless. + // docker-state.php returns 503 during the window the stock update_container + // flow rewrites the webui-info docker.json that getAllInfo() reads (the + // container list is momentarily empty though the daemon still has them). + // fetchSnapshot() throws on that non-200, so the catch below keeps the + // current state and we retry on the next resync — instead of blanking the + // page and poisoning the SWR cache with an empty list. A genuinely empty + // server returns 200 with an empty list and renders the empty state. const snapshot = await fetchSnapshot({ withStats: store.getShowStats() }); lastServerUptime = snapshot.serverUptime; store.setState({ @@ -167,11 +174,16 @@ export async function boot(): Promise { // snapshot of the in-flight pull/recreate sequence. The page mounts a panel // that consumes this. // - // The onBatchComplete callback fires on the script's `_DONE_` marker — we - // force-clear the docker store's updating set + refetch so the panel and - // per-row "update available" badges don't sit on stale state if the digest - // cache hasn't caught up yet. Without this fallback, "Working…" would - // linger until the 5-min watchdog fired. + // The onBatchComplete callback fires on the script's `_DONE_` marker. We + // resync THEN clear the updating set — order matters. reconcileUpdating() + // force-clears a recreated container's stale "update available" badge (the + // Unraid digest cache lags after a pull; see store.ts), but only while that + // container's update probe still exists. Clearing probes first — as this used + // to — left reconcile nothing to match, so the freshly-updated badge persisted + // on the lagging cache until a manual page reload. So: resync first (the + // rotated-id snapshot reconciles and clears the badge), then clearAllUpdating + // mops up any probe that didn't self-reconcile (a no-op/failed update) so the + // panel doesn't sit on "Working…" until the 5-min watchdog. const progressStore = createUpdateProgressStore( (image) => { // image string ("linuxserver/plex:latest") → container.name. Match @@ -185,8 +197,7 @@ export async function boot(): Promise { return null; }, () => { - store.clearAllUpdating(); - void resync(); + void resync().then(() => store.clearAllUpdating()); }, ); diff --git a/tests/unit-php/docker-state.test.php b/tests/unit-php/docker-state.test.php new file mode 100644 index 0000000..628a51e --- /dev/null +++ b/tests/unit-php/docker-state.test.php @@ -0,0 +1,53 @@ + { + it('defaults ON when the attribute is absent', () => { + delete document.documentElement.dataset.modernuiDocker; + expect(isDockerPageEnabled(document)).toBe(true); + }); + + it('returns false only when explicitly "off"', () => { + document.documentElement.dataset.modernuiDocker = 'off'; + expect(isDockerPageEnabled(document)).toBe(false); + document.documentElement.dataset.modernuiDocker = 'on'; + expect(isDockerPageEnabled(document)).toBe(true); + delete document.documentElement.dataset.modernuiDocker; + }); +}); diff --git a/tests/unit-ts/docker/store.test.ts b/tests/unit-ts/docker/store.test.ts index e07c1c2..46eac0e 100644 --- a/tests/unit-ts/docker/store.test.ts +++ b/tests/unit-ts/docker/store.test.ts @@ -408,6 +408,30 @@ describe('updating state', () => { expect(store.getState().containers[0].updateAvailable).toBe(false); }); + it('loses the badge force-clear if probes are cleared BEFORE the reconciling snapshot', () => { + // Ordering contract for boot.ts onBatchComplete (the stuck-badge bug): the + // id-rotation force-clear only runs while the update probe still exists. + // clearAllUpdating() before the resync's setState() deletes the probe, so + // reconcileUpdating() has nothing to match and the badge falls back to the + // snapshot's stale updateAvailable=true. onBatchComplete must therefore + // resync FIRST, then clear. This test pins that dependency so a future + // reorder back to clear-then-resync fails loudly. + const store = createDockerStore(); + const s = sampleState(); + s.containers[0] = mkContainer({ name: 'plex', id: 'old-id', updateAvailable: true }); + store.setState(s); + store.markUpdating(['plex']); + + // Wrong order: wipe probes before the recreated snapshot arrives. + store.clearAllUpdating(); + const next = sampleState(); + next.containers[0] = mkContainer({ name: 'plex', id: 'new-id', updateAvailable: true }); + store.setState(next); + + // No probe survived, so the lagging-cache badge persists — the bug. + expect(store.getState().containers[0].updateAvailable).toBe(true); + }); + it('clears entries when updateAvailable flips true→false (id unchanged path)', () => { const store = createDockerStore(); const s = sampleState();