From 816ac8c9d1ed9501b55d3b12503b51bb00d3a3e8 Mon Sep 17 00:00:00 2001 From: Inch-high Date: Sun, 7 Jun 2026 17:39:24 +0100 Subject: [PATCH 1/3] fix(docker): blank page on re-navigation, check-updates survival, cache busting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /Docker page could fail to load (entirely blank — no toolbar, no skeleton) when navigating away and back, and a "check for updates" run started before navigating gave no feedback after returning. Root causes and fixes: - Mount-point race: on the newer Unraid web-component shell the legacy .page body (our #modernui-docker-root) can be injected a tick after our head-loaded bundle runs. boot() did a one-shot querySelector and bailed permanently if absent, leaving the page blank with no retry. Now waits for the mount point via a MutationObserver (10s cap) before bailing. - Check-for-updates didn't survive navigation: the run is a detached server-side worker (PID lock), but the "Checking…" UI was a per-instance flag that reset on reload. boot() now re-attaches to an in-flight run and resumes polling, so the badges refresh on completion as if uninterrupted. - Completion was silent (especially the "0 updates" case). Added a transient, dismissible result banner: "N updates available" / "All containers up to date" / failure note. Fires on both the normal and resumed paths. - Stale-bundle blank after updates: loader.js injected the page bundles with no cache-busting (though loader.js + modernui.css already used filemtime busting). A plugin update could serve a stale bundle until a hard refresh. install.php now appends ?v= to each injected bundle URL. Also adds gated debug logging (enable with ?dockerdebug=1) across the boot / resync / poll path plus a server-side transient-503 log line, to make this class of "fails to load" report diagnosable from the console. Co-Authored-By: Claude Opus 4.8 (1M context) --- package/include/docker-state.php | 5 + package/include/install.php | 21 +++- src/ts/docker/actions.ts | 46 ++++++- src/ts/docker/boot.ts | 136 ++++++++++++++++++++- src/ts/docker/components/md-docker-page.ts | 131 ++++++++++++++++++++ src/ts/docker/debug.ts | 70 +++++++++++ 6 files changed, 397 insertions(+), 12 deletions(-) create mode 100644 src/ts/docker/debug.ts diff --git a/package/include/docker-state.php b/package/include/docker-state.php index 3771450..8345b57 100644 --- a/package/include/docker-state.php +++ b/package/include/docker-state.php @@ -303,6 +303,11 @@ function modernui_normalize_ports(array $rawPorts, string $url): array $transient = false; $snapshot = modernui_docker_state($withStats, $transient); if ($transient) { + // Logged so a "page failed to load during an update / check-for-updates" + // report can be correlated against the server clock — this 503 window is + // the stock backend mid-rewriting the webui-info docker.json. Front-end + // debug logging (?dockerdebug=1) records the matching client-side miss. + error_log('[modernui] docker-state transient 503 (webui-info mid-rewrite) — client keeps state and retries'); // 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 — diff --git a/package/include/install.php b/package/include/install.php index b87fdc8..9a084b1 100644 --- a/package/include/install.php +++ b/package/include/install.php @@ -153,6 +153,19 @@ function modernui_inject_script_tag(): void file_put_contents(MODERNUI_LAYOUT_FILE, $injected, LOCK_EX); } +// Build a dist asset URL with a cache-busting ?v= query so a plugin +// update doesn't leave the browser running a stale bundle (the classic +// blank-page-until-hard-refresh after an upgrade). Mirrors the filemtime +// busting already applied to loader.js + modernui.css via MODERNUI_SCRIPT_TAG / +// MODERNUI_STYLE_TAG. install.php runs after the new files are extracted, so the +// mtimes are fresh. Falls back to the bare URL if the file is unreadable. +function modernui_dist_asset_url(string $file): string +{ + $base = '/plugins/unraid-modernui/theme/dist/' . $file; + $mtime = @filemtime('/usr/local/emhttp/plugins/unraid-modernui/theme/dist/' . $file); + return $mtime ? $base . '?v=' . $mtime : $base; +} + function modernui_generate_loader_js(bool $disabled): void { $target = $disabled ? 're-enable.js' : 'modernui.js'; @@ -175,13 +188,13 @@ function modernui_generate_loader_js(bool $disabled): void $extraScript = ''; if (!$disabled) { $extraScript .= "var d=document.createElement('script');\n" - . "d.src='/plugins/unraid-modernui/theme/dist/modernui-dashboard.js';\n" + . "d.src='" . modernui_dist_asset_url('modernui-dashboard.js') . "';\n" . "document.head.appendChild(d);\n"; $extraScript .= "var dk=document.createElement('script');\n" - . "dk.src='/plugins/unraid-modernui/theme/dist/modernui-docker.js';\n" + . "dk.src='" . modernui_dist_asset_url('modernui-docker.js') . "';\n" . "document.head.appendChild(dk);\n"; $extraScript .= "var mn=document.createElement('script');\n" - . "mn.src='/plugins/unraid-modernui/theme/dist/modernui-main.js';\n" + . "mn.src='" . modernui_dist_asset_url('modernui-main.js') . "';\n" . "document.head.appendChild(mn);\n"; } $loader = "(function(){\n" @@ -197,7 +210,7 @@ function modernui_generate_loader_js(bool $disabled): void . 'r.dataset.modernuiDockerFolderDefault=' . json_encode($dockerFolderDefault) . ";\n" . 'r.dataset.modernuiDockerStats=' . json_encode($dockerShowStats) . ";\n" . "var s=document.createElement('script');\n" - . "s.src='/plugins/unraid-modernui/theme/dist/" . $target . "';\n" + . "s.src='" . modernui_dist_asset_url($target) . "';\n" . "document.head.appendChild(s);\n" . $extraScript . "})();\n"; diff --git a/src/ts/docker/actions.ts b/src/ts/docker/actions.ts index a192901..a3e7900 100644 --- a/src/ts/docker/actions.ts +++ b/src/ts/docker/actions.ts @@ -9,6 +9,7 @@ import type { DockerFoldersFile, DockerTagsFile, } from './types'; +import { dlog } from './debug'; export type DockerAction = 'start' | 'stop' | 'restart' | 'pause' | 'resume' | 'remove' | 'update'; @@ -127,6 +128,21 @@ export interface DockerSnapshot { serverUptime: number | null; } +// Error thrown by fetchSnapshot on a non-200. Carries the HTTP status so +// callers (and the debug log) can distinguish the transient 503 the stock +// backend serves while it rewrites the webui-info docker.json — during a +// container update or a check-for-updates run — from a genuine failure. +export class SnapshotError extends Error { + constructor(public readonly status: number) { + super(`docker-state.php ${status}`); + this.name = 'SnapshotError'; + } + /** The transient mid-rewrite window (see docker-state.php modernui_is_transient_empty). */ + get isTransient(): boolean { + return this.status === 503; + } +} + // withStats=true opts into the expensive VDisk + `docker stats` fetches on // the server. We only pass it when the Stats pill is on — otherwise the hot // snapshot path takes ~1s+ less (no shell-out to `docker stats --no-stream`, @@ -135,9 +151,33 @@ export interface DockerSnapshot { export async function fetchSnapshot(opts: { withStats?: boolean } = {}): Promise { const url = '/plugins/unraid-modernui/include/docker-state.php' + (opts.withStats ? '?stats=1' : ''); - const res = await fetch(url, { credentials: 'same-origin' }); - if (!res.ok) throw new Error(`docker-state.php ${res.status}`); - return res.json(); + const t = Date.now(); + let res: Response; + try { + res = await fetch(url, { credentials: 'same-origin' }); + } catch (err) { + // Network-level failure (offline, connection reset mid-update). Surfaced + // distinctly from an HTTP error so the debug trace shows which it was. + dlog('fetchSnapshot: network error', { withStats: !!opts.withStats, err: String(err) }); + throw err; + } + if (!res.ok) { + // 503 here is the expected transient window, not a bug — log it as such so + // the navigate-away-during-update repro reads clearly in the console. + dlog('fetchSnapshot: HTTP error', { + status: res.status, + transient: res.status === 503, + ms: Date.now() - t, + }); + throw new SnapshotError(res.status); + } + const snapshot = (await res.json()) as DockerSnapshot; + dlog('fetchSnapshot: ok', { + containers: snapshot.containers?.length ?? 0, + withStats: !!opts.withStats, + ms: Date.now() - t, + }); + return snapshot; } // ========================================================================= diff --git a/src/ts/docker/boot.ts b/src/ts/docker/boot.ts index 2345987..ab55196 100644 --- a/src/ts/docker/boot.ts +++ b/src/ts/docker/boot.ts @@ -1,8 +1,9 @@ import { createDockerStore, filtersFromQuery } from './store'; -import { fetchSnapshot } from './actions'; +import { fetchSnapshot, SnapshotError } from './actions'; import { readCachedSnapshot, writeCachedSnapshot } from './snapshot-cache'; import { createLiveSubscription } from './lifecycle'; import { createUpdateProgressStore } from './update-progress'; +import { dlog, isDockerDebug } from './debug'; import './components/md-docker-page'; import type { ModernuiDockerPage } from './components/md-docker-page'; import type { DockerDelta } from './types'; @@ -112,15 +113,87 @@ function resolveById( return null; } +// Wait for #modernui-docker-root to appear in the DOM, up to a timeout. Returns +// the element once present, or null if it never shows. Needed because the newer +// Unraid web-component shell can inject the legacy .page body (which contains our +// mount point) a tick after our head-loaded bundle executes — so a one-shot +// querySelector at script-eval time races and can miss it, especially on a +// re-navigation. A MutationObserver catches the insertion; the timeout stops us +// waiting forever on a page that genuinely has no mount point (stock UI). +function waitForRoot(timeoutMs = 10_000): Promise { + return new Promise((resolve) => { + const existing = document.querySelector('#modernui-docker-root'); + if (existing) { + resolve(existing); + return; + } + let settled = false; + const finish = (el: HTMLElement | null): void => { + if (settled) return; + settled = true; + observer.disconnect(); + window.clearTimeout(timer); + resolve(el); + }; + const observer = new MutationObserver(() => { + const el = document.querySelector('#modernui-docker-root'); + if (el) finish(el); + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + const timer = window.setTimeout(() => finish(null), timeoutMs); + }); +} + export async function boot(): Promise { - if (!isDockerPageEnabled(document)) return; - if (!onDockerPage()) return; + // Logged BEFORE the guards so we can tell "boot never ran" (no line at all) + // apart from "boot ran but bailed at a guard" (this line, then a bail line). + // The reported symptom — debug banner prints but nothing else — means boot + // returned at one of the three guards below without throwing. + dlog('boot: entry', { + path: window.location.pathname, + search: window.location.search, + readyState: document.readyState, + dockerFlag: document.documentElement.dataset.modernuiDocker, + rootPresent: !!document.querySelector('#modernui-docker-root'), + }); + + if (!isDockerPageEnabled(document)) { + dlog('boot: BAIL — page disabled (data-modernui-docker=off)'); + return; + } + if (!onDockerPage()) { + dlog('boot: BAIL — not on /Docker', { pathname: window.location.pathname }); + return; + } + + let root = document.querySelector('#modernui-docker-root'); + if (!root) { + // The mount point wasn't in the DOM at script-eval time. On the newer Unraid + // web-component shell the legacy .page content (our root div) can be injected + // a tick AFTER our head-loaded bundle runs — especially on a re-navigation — + // so bailing immediately (as before) left the page permanently blank. Wait + // for it instead of giving up. + dlog('boot: root absent at entry — waiting for mount point', { + readyState: document.readyState, + }); + root = await waitForRoot(); + if (!root) { + dlog('boot: BAIL — mount point never appeared'); + return; + } + dlog('boot: root appeared after wait'); + } - const root = document.querySelector('#modernui-docker-root'); - if (!root) return; // mount point absent → stock page is rendering, bail silently + dlog('boot: start', { path: window.location.pathname, search: window.location.search }); const store = createDockerStore(); store.setFilters(filtersFromQuery(window.location.search)); + // Surface how many "updating" probes survived the navigation. This is the + // crux of the navigate-away-during-update repro: a container UPDATE persists + // a probe (so md-docker-page restarts a poll that recovers from a boot 503), + // but a CHECK-FOR-UPDATES persists nothing — so if the boot resync 503s with + // no usable cache, nothing retries and the page stays on the skeleton. + dlog('boot: hydrated updating probes', { count: store.getUpdating().size }); // Default folder state (Expanded / Collapsed) flows from Settings → Theme // via loader.js → dataset attribute on . const defaultFolderState = document.documentElement.dataset.modernuiDockerFolderDefault; @@ -164,7 +237,32 @@ export async function boot(): Promise { }); // Stash for the next boot's instant paint (SWR). Best-effort. writeCachedSnapshot(snapshot); + dlog('resync: applied snapshot', { + containers: snapshot.containers.length, + loading: store.isLoading(), + }); } catch (err) { + // A transient 503 means the stock backend is mid-rewrite (update or + // check-for-updates in flight). We keep the current state and retry on the + // next resync — but on a COLD boot there's no current state and (for a + // check-for-updates) no probe-driven poll to retry, so loading stays true + // and the page sits on the skeleton. That stuck-loading combination is the + // signature of the reported bug; logging it makes it unmistakable. + const transient = err instanceof SnapshotError && err.isTransient; + dlog('resync: FAILED', { + transient, + status: err instanceof SnapshotError ? err.status : undefined, + stillLoading: store.isLoading(), + haveContainers: store.getState().containers.length, + updatingProbes: store.getUpdating().size, + err: String(err), + }); + if (transient && store.isLoading()) { + dlog( + 'resync: STUCK — boot resync 503 with empty store and no rendered rows; ' + + 'nothing will retry unless an updating probe or a visibility flip fires', + ); + } console.warn('[modernui-docker] snapshot failed:', err); } } @@ -197,6 +295,7 @@ export async function boot(): Promise { return null; }, () => { + dlog('progress: _DONE_ batch complete — resync then clearAllUpdating'); void resync().then(() => store.clearAllUpdating()); }, ); @@ -219,9 +318,36 @@ export async function boot(): Promise { tags: cached.tags, tagAssignments: cached.tagAssignments, }); + dlog('cache: HIT — hydrated from sessionStorage', { containers: cached.containers.length }); + } else { + // No cache → if the resync below 503s, the store has nothing to paint and + // loading never clears. This is the cold-boot half of the bug. + dlog('cache: MISS — no usable SWR snapshot; loading state depends on resync'); + } + + // Expose the live stores for console inspection while debugging, e.g. + // window.__modernuiDocker.store.isLoading(). Only attached when debug is on. + if (isDockerDebug()) { + (window as unknown as { __modernuiDocker?: unknown }).__modernuiDocker = { + store, + progressStore, + resync, + }; } await resync(); + dlog('boot: initial resync settled', { + loading: store.isLoading(), + containers: store.getState().containers.length, + }); + + // If a "check for updates" worker is still running server-side (the user + // started one, then navigated away and back), re-attach the "Checking…" UI + // and resume polling so the page reflects the in-flight run and refreshes the + // update badges when it completes — instead of looking idle while the worker + // keeps walking the registry. Fire-and-forget; it self-cancels if nothing is + // running. + void page.resumeCheckForUpdatesIfRunning(); // Detect a post-reboot autostart-in-progress sequence. rc.docker reads // /var/lib/docker/unraid-autostart at boot and starts each listed container diff --git a/src/ts/docker/components/md-docker-page.ts b/src/ts/docker/components/md-docker-page.ts index 798d5e9..cf5cd94 100644 --- a/src/ts/docker/components/md-docker-page.ts +++ b/src/ts/docker/components/md-docker-page.ts @@ -23,6 +23,7 @@ import { openEdit, type DockerAction, } from '../actions'; +import { dlog } from '../debug'; import type { DockerRowActionDetail } from './md-docker-row'; import type { BulkAction } from './md-docker-bulk-bar'; import './md-docker-toolbar'; @@ -179,6 +180,30 @@ export class ModernuiDockerPage extends LitElement { border-radius: var(--radius-md); } .empty strong { display: block; font-size: 16px; color: var(--text-primary); margin-bottom: 4px; } + + /* Transient result banner shown when a "check for updates" run completes. + Auto-dismisses; tone-coloured leading dot mirrors the header stat pills. */ + .check-summary { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; + margin-bottom: 16px; + border-radius: var(--radius-md); + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + font-size: 13px; + color: var(--text-primary); + } + .check-summary .cs-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; } + .check-summary.info .cs-dot { background: var(--info); } + .check-summary.success .cs-dot { background: var(--success); } + .check-summary.danger .cs-dot { background: var(--danger); } + .check-summary .cs-text { flex: 1 1 auto; } + .check-summary .cs-close { + background: transparent; border: 0; color: var(--text-muted); + cursor: pointer; font-size: 18px; line-height: 1; padding: 2px 6px; + border-radius: var(--radius-sm); + } + .check-summary .cs-close:hover { color: var(--text-primary); background: var(--bg-elevated); } `; private _store: DockerStore | null = null; @@ -192,6 +217,14 @@ export class ModernuiDockerPage extends LitElement { @state() private _showFolderModal = false; @state() private _showTagModal = false; @state() private _checkingUpdates = false; + // Transient banner shown when a "check for updates" run completes, summarising + // the result (e.g. "3 updates available" / "All containers up to date"). Auto- + // dismisses after a few seconds; _checkSummaryHandle tracks that timer so it's + // cancelled on teardown / replacement. Without this the completion is silent — + // badges just appear — and a "0 updates" result gives no feedback at all. + @state() private _checkSummary: { tone: 'info' | 'success' | 'danger'; text: string } | null = + null; + private _checkSummaryHandle: number | null = null; // Styled replacement for window.confirm(). When set, the confirm modal is // rendered; _confirmResolve settles the promise handed back by _askConfirm(). @state() private _confirm: ConfirmConfig | null = null; @@ -260,6 +293,48 @@ export class ModernuiDockerPage extends LitElement { window.clearTimeout(this._startingPollHandle); this._startingPollHandle = null; } + if (this._checkSummaryHandle !== null) { + window.clearTimeout(this._checkSummaryHandle); + this._checkSummaryHandle = null; + } + } + + // Show the post-check result banner. tone drives the colour/icon; the banner + // auto-dismisses after AUTO_DISMISS_MS (a prior pending dismiss is cleared so + // a fresh result gets the full window). The user can also dismiss it manually. + private static readonly CHECK_SUMMARY_AUTO_DISMISS_MS = 8000; + private _showCheckSummary(tone: 'info' | 'success' | 'danger', text: string): void { + if (this._checkSummaryHandle !== null) window.clearTimeout(this._checkSummaryHandle); + this._checkSummary = { tone, text }; + this._checkSummaryHandle = window.setTimeout(() => { + this._checkSummary = null; + this._checkSummaryHandle = null; + }, ModernuiDockerPage.CHECK_SUMMARY_AUTO_DISMISS_MS); + } + + // Summarise the snapshot after a completed check: count containers flagged + // updateAvailable and surface "N updates available" / "All containers up to + // date" — or a failure note when the worker reported an error. Pulled out so + // both the normal completion path and the resumed-run path share one place. + private _announceCheckResult(error: string | null): void { + if (error) { + this._showCheckSummary('danger', 'Update check failed — see logs'); + return; + } + const found = this._store?.getState().containers.filter((c) => c.updateAvailable).length ?? 0; + if (found === 0) { + this._showCheckSummary('success', 'All containers up to date'); + } else { + this._showCheckSummary('info', `${found} update${found === 1 ? '' : 's'} available`); + } + } + + private _dismissCheckSummary(): void { + if (this._checkSummaryHandle !== null) { + window.clearTimeout(this._checkSummaryHandle); + this._checkSummaryHandle = null; + } + this._checkSummary = null; } // Promise-based styled confirm. Replaces window.confirm() — resolves true if @@ -581,6 +656,7 @@ export class ModernuiDockerPage extends LitElement { private _runCheckForUpdates = async (): Promise => { if (!this._store || this._checkingUpdates) return; this._checkingUpdates = true; + dlog('check-for-updates: started'); try { // Baseline the previous run's completion timestamp BEFORE spawning the // worker. The poll loop uses it to tell "this run finished" apart from a @@ -602,6 +678,39 @@ export class ModernuiDockerPage extends LitElement { } }; + // Re-attach to a check-for-updates run that's still in flight server-side. + // + // checkForUpdates() spawns a DETACHED PHP worker whose run state lives in a + // server PID lock (see docker-check-updates.php), so the work continues across + // a page navigation — but _checkingUpdates is a per-instance flag that resets + // to false on every fresh load. Without this, navigating away and back during + // a check shows the button idle ("Check for updates") even though the worker + // is still walking the registry, and the new updateAvailable badges only + // appear on some later incidental resync — so the user can't tell the check + // finished. Called from boot.ts after mount: if the worker is still running we + // restore the "Checking…" UI and resume the poll, which refreshes the snapshot + // on completion exactly like the original run would have. + resumeCheckForUpdatesIfRunning = async (): Promise => { + if (!this._store || this._checkingUpdates) return; + try { + const status = await getCheckUpdatesStatus(); + if (!status.running) { + dlog('check-for-updates: no run in flight on boot', { finishedAt: status.finishedAt }); + return; + } + dlog('check-for-updates: resuming in-flight run after navigation'); + this._checkingUpdates = true; + // Baseline at the worker's current finishedAt. It's running now, so the + // poll will observe running:true (sets sawRunning) and conclude on the + // real completion — then refreshSnapshot() paints the new badges. + this._pollCheckUpdates(status.finishedAt); + } catch (err) { + // Status probe is best-effort; a failure just means we don't resume the + // optimistic UI (the next resync still eventually reflects the result). + dlog('check-for-updates: resume probe failed', { err: String(err) }); + } + }; + // Poll every 2s. Pauses while the tab is hidden (cheap retries kicked off // on visibilitychange would still serve us, but tab-hidden polling burns // background CPU on long-lived tabs — see unraid/webgui#2641). @@ -667,12 +776,14 @@ export class ModernuiDockerPage extends LitElement { console.warn( '[modernui-docker] check-for-updates watchdog: no running worker for 60s, giving up', ); + dlog('check-for-updates: watchdog gave up (no running worker for 60s)'); try { await refreshSnapshot(); } catch { /* snapshot is best-effort here */ } this._checkingUpdates = false; + this._showCheckSummary('danger', 'Update check timed out'); return; } try { @@ -685,15 +796,22 @@ export class ModernuiDockerPage extends LitElement { // run has genuinely completed — NOT on the first premature running:false // the detached worker can momentarily emit before it writes its lock. if (!checkUpdatesCompleted(status, sawRunning, baselineFinishedAt)) { + dlog('check-for-updates: still running', { running: status.running, sawRunning }); reschedule(); return; } if (status.error) { console.warn('[modernui-docker] check-for-updates worker error:', status.error); } + dlog('check-for-updates: completed, refreshing snapshot', { error: status.error }); await refreshSnapshot(); this._checkingUpdates = false; + // Surface the outcome now that the snapshot carries the fresh + // updateAvailable flags — including the "0 / up to date" case, which + // otherwise produces no visible change at all. + this._announceCheckResult(status.error); } catch (err) { + dlog('check-for-updates: poll failed', { err: String(err) }); console.warn('[modernui-docker] status poll failed:', err); this._checkingUpdates = false; } @@ -980,6 +1098,19 @@ export class ModernuiDockerPage extends LitElement { + ${ + this._checkSummary + ? html` +
+ + ${this._checkSummary.text} + +
+ ` + : nothing + } + ): void { + if (!enabled) return; + const secs = ((Date.now() - t0) / 1000).toFixed(2).padStart(7); + if (data !== undefined) { + // eslint-disable-next-line no-console + console.debug(`[modernui-docker +${secs}s] ${event}`, data); + } else { + // eslint-disable-next-line no-console + console.debug(`[modernui-docker +${secs}s] ${event}`); + } +} + +// One-time banner so the user knows logging is live (and how to turn it off). +if (enabled) { + dlog( + 'debug logging ENABLED — disable with ?dockerdebug=0 or remove the modernui-docker-debug localStorage key', + ); +} From 6f093418acec653c9f7682eef4562096e7c61c2e Mon Sep 17 00:00:00 2001 From: Inch-high Date: Sun, 7 Jun 2026 17:42:39 +0100 Subject: [PATCH 2/3] fix(docker): make debug flag per-session, not permanent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ?dockerdebug=1 flag was persisted to localStorage, so once enabled it stayed on across every page load forever — surprising and noisy. Move it to sessionStorage: it still survives a same-tab navigate-away-and-back (the repro this logging is for), but clears when the tab closes. Also drop any leftover localStorage flag on load so previously-enabled sessions turn off. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ts/docker/debug.ts | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/ts/docker/debug.ts b/src/ts/docker/debug.ts index 586722e..100ca82 100644 --- a/src/ts/docker/debug.ts +++ b/src/ts/docker/debug.ts @@ -3,11 +3,17 @@ // OFF by default — zero console noise in normal use. Enable it at runtime (no // rebuild) by either: // -// • appending ?dockerdebug=1 to the /Docker URL (persists to localStorage), -// • or running localStorage['modernui-docker-debug'] = '1' in the console, +// • appending ?dockerdebug=1 to the /Docker URL, or +// • running sessionStorage['modernui-docker-debug'] = '1' in the console, // -// then reloading. Disable with ?dockerdebug=0 or -// localStorage.removeItem('modernui-docker-debug'). +// then reloading. Disable with ?dockerdebug=0, by closing the tab, or with +// sessionStorage.removeItem('modernui-docker-debug'). +// +// The flag lives in sessionStorage (not localStorage) on purpose: it must +// survive a same-tab navigate-away-and-back — the exact repro this logging +// exists for, and sessionStorage does survive full reloads within a tab — but +// it must NOT stick forever. sessionStorage clears when the tab closes, so an +// enabled session can't silently follow the user around for days. // // Purpose: the "page fails to load when you navigate away and back while an // update / check-for-updates is running" report is hard to catch live, because @@ -19,19 +25,27 @@ const FLAG_KEY = 'modernui-docker-debug'; -// Apply a ?dockerdebug=1/0 URL override to the persisted flag, then read it. -// Wrapped because both localStorage and URL parsing can throw (private mode, +// Apply a ?dockerdebug=1/0 URL override to the (session) flag, then read it. +// Wrapped because both Web Storage and URL parsing can throw (private mode, // exotic URLs) and debug logging must never break boot. function resolveEnabled(): boolean { + // Migration: an earlier build persisted the flag to localStorage, which stuck + // forever. Drop any such leftover so it can't keep logging enabled — the live + // toggle is sessionStorage only now. + try { + localStorage.removeItem(FLAG_KEY); + } catch { + /* ignore */ + } try { const v = new URLSearchParams(window.location.search).get('dockerdebug'); - if (v === '1') localStorage.setItem(FLAG_KEY, '1'); - else if (v === '0') localStorage.removeItem(FLAG_KEY); + if (v === '1') sessionStorage.setItem(FLAG_KEY, '1'); + else if (v === '0') sessionStorage.removeItem(FLAG_KEY); } catch { /* ignore */ } try { - return localStorage.getItem(FLAG_KEY) === '1'; + return sessionStorage.getItem(FLAG_KEY) === '1'; } catch { return false; } @@ -65,6 +79,6 @@ export function dlog(event: string, data?: Record): void { // One-time banner so the user knows logging is live (and how to turn it off). if (enabled) { dlog( - 'debug logging ENABLED — disable with ?dockerdebug=0 or remove the modernui-docker-debug localStorage key', + 'debug logging ENABLED — disable with ?dockerdebug=0 or by closing this tab (flag is per-session)', ); } From 942a0154e3f46416fa7fe40d4f8b5d2c2bc52426 Mon Sep 17 00:00:00 2001 From: Inch-high Date: Sun, 7 Jun 2026 17:48:39 +0100 Subject: [PATCH 3/3] test(docker): cover the check-for-updates completion banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The poll-check-updates tests gave the page a mock store without getState, so _announceCheckResult() (added with the result banner) threw a swallowed TypeError on every completion tick — the banner path ran in production but had zero test coverage and spewed misleading "status poll failed" noise. Add getState to the mock (parameterised by container list) and assert the banner: the "all up to date" case on the existing completion test, plus a new test for the pluralised "N updates available" count. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../unit-ts/docker/poll-check-updates.test.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/unit-ts/docker/poll-check-updates.test.ts b/tests/unit-ts/docker/poll-check-updates.test.ts index c754819..431fdf1 100644 --- a/tests/unit-ts/docker/poll-check-updates.test.ts +++ b/tests/unit-ts/docker/poll-check-updates.test.ts @@ -44,12 +44,18 @@ const POLL_MAX_MS = 60_000; // loop, and return the page + a setState spy. Cast to `any` — `_pollCheckUpdates` // and `_checkingUpdates` are private, but driving the real method is the whole // point (the regression was in this method, not in any public surface). -function startPoll(baselineFinishedAt: number | null) { +function startPoll( + baselineFinishedAt: number | null, + containers: Array<{ updateAvailable?: boolean }> = [], +) { const setState = vi.fn(); // Cast to `any` to drive the private poll loop directly — the regression was // in this method, so testing it through the real method is the point. const page = new ModernuiDockerPage() as any; - page._store = { getShowStats: () => false, setState }; + // getState feeds the post-completion result banner (_announceCheckResult), + // which counts updateAvailable containers — supply it so that path runs for + // real instead of throwing and being swallowed by the poll's catch. + page._store = { getShowStats: () => false, setState, getState: () => ({ containers }) }; page._checkingUpdates = true; page._pollCheckUpdates(baselineFinishedAt); return { page, setState }; @@ -99,6 +105,24 @@ describe('_pollCheckUpdates orchestration', () => { expect(page._checkingUpdates).toBe(false); expect(fetchSnapshotMock).toHaveBeenCalledTimes(1); expect(setState).toHaveBeenCalledTimes(1); + // Completion also surfaces the result banner — here the "all up to date" + // (zero updateAvailable) case, which otherwise produces no visible change. + expect(page._checkSummary).toEqual({ tone: 'success', text: 'All containers up to date' }); + }); + + it('announces the update count in the completion banner', async () => { + // Two of three containers flag updateAvailable → the banner pluralises the + // count. Exercises the _announceCheckResult path that reads store state. + getStatusMock.mockResolvedValue(status({ running: false, finishedAt: 1005 })); + const { page } = startPoll(1000, [ + { updateAvailable: true }, + { updateAvailable: false }, + { updateAvailable: true }, + ]); + + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + expect(page._checkingUpdates).toBe(false); + expect(page._checkSummary).toEqual({ tone: 'info', text: '2 updates available' }); }); it('concludes promptly on a fast host (fresh finishedAt, never seen running)', async () => {