fix(control-ui): add static mount fallback

This commit is contained in:
Val Alexander
2026-05-11 07:01:02 -05:00
parent 8f1e6ab13c
commit 6f12cb27d9
4 changed files with 308 additions and 0 deletions
+1
View File
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
- CI: add a non-blocking `plugin-inspector-advisory` artifact to Plugin Prerelease so release runs capture bundled plugin compatibility triage without changing the blocking gate.
- Providers/fal: route GPT Image 2 and Nano Banana 2 reference-image edit requests to `/edit` with `image_urls` array, enforce NB2 edit geometry using `aspect_ratio` and `resolution` params, lift Fal edit mode input-image caps to 10 for GPT Image 2 and 14 for Nano Banana 2, and allow aspect-ratio hints in edit mode. (#77295) Thanks @leoge007.
- Control UI: show a plain HTML recovery panel when the app module never registers, giving blank dashboard pages a retry path and browser-extension troubleshooting link. Fixes #44107. Thanks @BunsDev.
- Build: enable additional low-churn oxlint rules for promise, TypeScript, and runtime footgun checks.
- Build: enable stricter Vitest lint rules for focused, disabled, conditional, hook, matcher, and expectation hazards.
+10
View File
@@ -425,6 +425,16 @@ pnpm ui:dev
Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).
## Blank Control UI page
If the browser loads a blank dashboard and DevTools shows no useful error, an extension or early content script may have prevented the JavaScript module app from evaluating. The static page includes a plain HTML recovery panel that appears when `<openclaw-app>` is not registered after startup.
Use the panel's **Try again** action after changing the browser environment, or reload manually after these checks:
- Disable extensions that inject into all pages, especially extensions with `<all_urls>` content scripts.
- Try a private window, a clean browser profile, or another browser.
- Keep the Gateway running and verify the same dashboard URL after the browser change.
## Debugging/testing: dev server + remote Gateway
The Control UI is static files; the WebSocket target is configurable and can be different from the HTTP origin. This is handy when you want the Vite dev server locally but the Gateway runs elsewhere.
+235
View File
@@ -62,9 +62,244 @@
} catch (e) {}
})();
</script>
<style>
body.openclaw-mount-fallback-active {
margin: 0;
min-width: 320px;
color: #eef4f8;
background: #101418;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
body.openclaw-mount-fallback-active openclaw-app {
display: none;
}
.mount-fallback {
box-sizing: border-box;
min-height: 100vh;
padding: 24px;
place-items: center;
}
.mount-fallback:not([hidden]) {
display: grid;
}
.mount-fallback__panel {
box-sizing: border-box;
width: min(100%, 640px);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
background: #151b21;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.36);
padding: 28px;
}
.mount-fallback__eyebrow {
margin: 0 0 10px;
color: #9fb0bd;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mount-fallback__panel h1 {
margin: 0;
color: inherit;
font-size: clamp(1.5rem, 3vw, 2rem);
line-height: 1.15;
}
.mount-fallback__panel p {
margin: 16px 0 0;
color: #c8d3da;
font-size: 1rem;
line-height: 1.6;
}
.mount-fallback__panel ul {
margin: 18px 0 0;
padding-left: 1.2rem;
color: #c8d3da;
line-height: 1.6;
}
.mount-fallback__panel a {
color: #8bd3ff;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.18em;
}
.mount-fallback__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 24px;
}
.mount-fallback__button {
min-height: 42px;
border: 1px solid rgba(148, 163, 184, 0.36);
border-radius: 6px;
padding: 0 16px;
color: inherit;
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.mount-fallback__button--primary {
border-color: #66c2ff;
color: #061019;
background: #8bd3ff;
}
.mount-fallback__button:focus-visible,
.mount-fallback__panel a:focus-visible {
outline: 3px solid #f9c74f;
outline-offset: 3px;
}
html[data-theme-mode="light"] body.openclaw-mount-fallback-active {
color: #151b21;
background: #f5f7fa;
}
html[data-theme-mode="light"] .mount-fallback__panel {
border-color: rgba(71, 85, 105, 0.22);
background: #ffffff;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.14);
}
html[data-theme-mode="light"] .mount-fallback__eyebrow,
html[data-theme-mode="light"] .mount-fallback__panel p,
html[data-theme-mode="light"] .mount-fallback__panel ul {
color: #4b5963;
}
html[data-theme-mode="light"] .mount-fallback__panel a {
color: #0369a1;
}
</style>
</head>
<body>
<openclaw-app></openclaw-app>
<section
id="openclaw-mount-fallback"
class="mount-fallback"
data-openclaw-mount-timeout-ms="12000"
role="alert"
aria-labelledby="openclaw-mount-fallback-title"
hidden
>
<div class="mount-fallback__panel">
<p class="mount-fallback__eyebrow">OpenClaw Control UI</p>
<h1 id="openclaw-mount-fallback-title">Control UI did not start</h1>
<p>
The browser loaded the static page, but the app bundle did not register the
<code>openclaw-app</code> web component. A browser extension or early content script may
be blocking module execution.
</p>
<ul>
<li>Try again in a clean browser profile or private window.</li>
<li>Disable extensions that run on all pages, then reload this dashboard.</li>
<li>
See
<a
href="https://docs.openclaw.ai/web/control-ui#blank-control-ui-page"
target="_blank"
rel="noreferrer"
>Control UI troubleshooting</a
>.
</li>
</ul>
<div class="mount-fallback__actions">
<button
type="button"
id="openclaw-mount-retry"
class="mount-fallback__button mount-fallback__button--primary"
>
Try again
</button>
<button type="button" id="openclaw-mount-dismiss" class="mount-fallback__button">
Hide message
</button>
</div>
</div>
</section>
<script>
(function () {
var tagName = "openclaw-app";
var app = document.querySelector(tagName);
var fallback = document.getElementById("openclaw-mount-fallback");
if (!app || !fallback) return;
var dismissed = false;
var retry = document.getElementById("openclaw-mount-retry");
var dismiss = document.getElementById("openclaw-mount-dismiss");
var rawDelay = Number(fallback.getAttribute("data-openclaw-mount-timeout-ms"));
var delay = Number.isFinite(rawDelay) && rawDelay > 0 ? rawDelay : 12000;
function appMounted() {
try {
return Boolean(
app.isConnected &&
window.customElements &&
typeof window.customElements.get === "function" &&
window.customElements.get(tagName),
);
} catch (e) {
return false;
}
}
function hideFallback() {
fallback.hidden = true;
document.body.classList.remove("openclaw-mount-fallback-active");
}
function showFallback() {
if (dismissed || appMounted()) return;
fallback.hidden = false;
document.body.classList.add("openclaw-mount-fallback-active");
}
var timer = window.setTimeout(showFallback, delay);
if (window.customElements && typeof window.customElements.whenDefined === "function") {
window.customElements.whenDefined(tagName).then(
function () {
window.clearTimeout(timer);
hideFallback();
},
function () {},
);
}
if (retry) {
retry.addEventListener("click", function () {
window.location.reload();
});
}
if (dismiss) {
dismiss.addEventListener("click", function () {
dismissed = true;
window.clearTimeout(timer);
hideFallback();
});
}
})();
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const indexHtmlPath = path.resolve(process.cwd(), "ui/index.html");
async function readIndexHtmlWithDelay(delayMs: number): Promise<string> {
const html = await readFile(indexHtmlPath, "utf8");
return html.replace(
'data-openclaw-mount-timeout-ms="12000"',
`data-openclaw-mount-timeout-ms="${delayMs}"`,
);
}
function waitForWindowTimeout(window: Window, delayMs: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, delayMs);
});
}
function installFallbackShell(html: string): void {
const parsed = new DOMParser().parseFromString(html, "text/html");
document.head.innerHTML = parsed.head.innerHTML;
document.body.innerHTML = parsed.body.innerHTML;
const sentinel = Array.from(parsed.querySelectorAll("script:not([src])")).find((script) =>
script.textContent?.includes("openclaw-mount-fallback"),
);
expect(sentinel).toBeTruthy();
window.eval(sentinel?.textContent ?? "");
}
describe("Control UI mount fallback", () => {
afterEach(() => {
document.head.innerHTML = "";
document.body.innerHTML = "";
});
it("shows the static troubleshooting panel when the app element is never registered", async () => {
installFallbackShell(await readIndexHtmlWithDelay(1));
await waitForWindowTimeout(window, 10);
const fallback = document.getElementById("openclaw-mount-fallback");
expect(fallback?.hidden).toBe(false);
expect(document.body.classList.contains("openclaw-mount-fallback-active")).toBe(true);
expect(fallback?.textContent).toContain("Control UI did not start");
expect(fallback?.textContent).toContain("Control UI troubleshooting");
});
it("keeps the fallback hidden when the app element registers before the timeout", async () => {
installFallbackShell(await readIndexHtmlWithDelay(25));
if (!window.customElements.get("openclaw-app")) {
window.customElements.define("openclaw-app", class extends HTMLElement {});
}
await window.customElements.whenDefined("openclaw-app");
await waitForWindowTimeout(window, 35);
const fallback = document.getElementById("openclaw-mount-fallback");
expect(fallback?.hidden).toBe(true);
expect(document.body.classList.contains("openclaw-mount-fallback-active")).toBe(false);
});
});