Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions package/include/docker-state.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,42 @@
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:
// getAllInfo() → per-container user metadata (icon, url, autostart, template, "updated")
// 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();
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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();

Expand Down Expand Up @@ -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);
}
}
25 changes: 18 additions & 7 deletions src/ts/docker/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,13 @@ export async function boot(): Promise<void> {
// 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({
Expand All @@ -167,11 +174,16 @@ export async function boot(): Promise<void> {
// 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
Expand All @@ -185,8 +197,7 @@ export async function boot(): Promise<void> {
return null;
},
() => {
store.clearAllUpdating();
void resync();
void resync().then(() => store.clearAllUpdating());
},
);

Expand Down
53 changes: 53 additions & 0 deletions tests/unit-php/docker-state.test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

// Tests for docker-state.php's transient-empty disambiguation.
//
// Background: while the stock update_container flow recreates a container it
// rewrites the webui-info docker.json that DockerTemplates::getAllInfo() reads,
// so getAllInfo() momentarily returns an empty list even though the docker
// daemon still has every container. Serving that empty list blanked the
// modernui /Docker page. getDockerContainers() hits the docker socket directly
// (the source of truth, unaffected by the rewrite), so modernui_is_transient_empty()
// distinguishes "mid-rewrite" (info empty, daemon non-empty → 503) from a
// genuinely empty server (both empty → 200 with []).
//
// We can't exercise modernui_docker_state() end-to-end — it needs Unraid's
// DockerTemplates/DockerClient, absent in the sandbox — but its bootstrap is
// guarded by is_file()/PHP_SAPI so requiring the file is safe and the pure
// helper is testable in isolation.

// Quiet the bootstrap's $_SERVER['DOCUMENT_ROOT'] read under CLI.
$_SERVER['DOCUMENT_ROOT'] = $_SERVER['DOCUMENT_ROOT'] ?? sys_get_temp_dir();

require_once __DIR__ . '/../../package/include/docker-state.php';

// ===========================================================================
// modernui_is_transient_empty()
// ===========================================================================

assert(
modernui_is_transient_empty(0, 43) === true,
'info empty + daemon non-empty → transient (mid-rewrite)'
);

assert(
modernui_is_transient_empty(0, 0) === false,
'both empty → genuinely empty server, NOT transient (must render empty state)'
);

assert(
modernui_is_transient_empty(43, 43) === false,
'steady state: info and daemon both populated → not transient'
);

assert(
modernui_is_transient_empty(43, 0) === false,
'info populated but daemon empty (odd) → not flagged; only an empty info triggers'
);

assert(
modernui_is_transient_empty(1, 0) === false,
'a single info row is non-empty → not transient'
);

echo "docker-state.test.php OK\n";
25 changes: 25 additions & 0 deletions tests/unit-ts/docker/boot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { isDockerPageEnabled } from '../../../src/ts/docker/boot';

// The transient-empty-snapshot guard that used to live here moved server-side:
// docker-state.php now returns 503 (not 200 + `containers: []`) while the stock
// update_container flow rewrites webui-info docker.json, so resync()'s catch
// keeps current state. See modernui_is_transient_empty in docker-state.php and
// docker-state.test.php. Removing the client heuristic also fixes the inverse
// bug — a genuinely empty server (all containers deleted) now renders the empty
// state instead of being suppressed as "transient".

describe('isDockerPageEnabled gate', () => {
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;
});
});
24 changes: 24 additions & 0 deletions tests/unit-ts/docker/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down