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 by closing this tab (flag is per-session)', + ); +} 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 () => {