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
5 changes: 5 additions & 0 deletions package/include/docker-state.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
21 changes: 17 additions & 4 deletions package/include/install.php
Original file line number Diff line number Diff line change
Expand Up @@ -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=<mtime> 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';
Expand All @@ -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"
Expand All @@ -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";
Expand Down
46 changes: 43 additions & 3 deletions src/ts/docker/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
DockerFoldersFile,
DockerTagsFile,
} from './types';
import { dlog } from './debug';

export type DockerAction = 'start' | 'stop' | 'restart' | 'pause' | 'resume' | 'remove' | 'update';

Expand Down Expand Up @@ -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`,
Expand All @@ -135,9 +151,33 @@ export interface DockerSnapshot {
export async function fetchSnapshot(opts: { withStats?: boolean } = {}): Promise<DockerSnapshot> {
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;
}

// =========================================================================
Expand Down
136 changes: 131 additions & 5 deletions src/ts/docker/boot.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<HTMLElement | null> {
return new Promise((resolve) => {
const existing = document.querySelector<HTMLElement>('#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<HTMLElement>('#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<void> {
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<HTMLElement>('#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<HTMLElement>('#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 <html>.
const defaultFolderState = document.documentElement.dataset.modernuiDockerFolderDefault;
Expand Down Expand Up @@ -164,7 +237,32 @@ export async function boot(): Promise<void> {
});
// 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);
}
}
Expand Down Expand Up @@ -197,6 +295,7 @@ export async function boot(): Promise<void> {
return null;
},
() => {
dlog('progress: _DONE_ batch complete — resync then clearAllUpdating');
void resync().then(() => store.clearAllUpdating());
},
);
Expand All @@ -219,9 +318,36 @@ export async function boot(): Promise<void> {
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
Expand Down
Loading