mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-09-03 07:24:52 +08:00
Two faults, both surfacing as a page that refreshes without end. The Worker checked container readiness and then proxied, which are not atomic: the idle timeout can reap the container in between, leaving the Durable Object reporting a state that is no longer true. The platform then answers with its own "not listening in the TCP address" error, which reached the visitor verbatim instead of the loading page. Detecting that is awkward — it arrives as a plain-text 5xx body rather than a throw, so there is nothing to catch and no status or header that separates it from an application error. Match the message, treat it as not-ready, and fall through to the loading path. The first attempt at this also called recycle() on detection, which was worse than the bug: recycle() stops the container, and by then ready() had already started a new boot, so every request killed the container that was trying to start. Nothing could ever reach healthy and the page refreshed indefinitely. Re-arm the boot, never stop it. Independently, harden the page itself. It reloads when the container reports ready, so a container that flaps made it reload endlessly. Cap reloads within a two-minute window and show an explanation with a manual retry instead. The window matters: spaced-out reloads are ordinary cold starts across a long session and must not accumulate into a false positive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
5.3 KiB
TypeScript
157 lines
5.3 KiB
TypeScript
/**
|
|
* The page shown while the demo container is booting.
|
|
*
|
|
* Must be completely self-contained. During a cold start nothing under
|
|
* /assets/* is reachable, because the SPA is served from inside the container
|
|
* that has not started yet — so a single external stylesheet, font or image
|
|
* reference would leave the loading page itself broken.
|
|
*/
|
|
|
|
const SPINNER = `<svg class="spinner" viewBox="0 0 50 50" aria-hidden="true">
|
|
<circle cx="25" cy="25" r="20" fill="none" stroke-width="4" />
|
|
</svg>`
|
|
|
|
export function loadingPage(): string {
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<meta name="robots" content="noindex">
|
|
<title>Starting Nginx UI demo…</title>
|
|
<style>
|
|
:root { color-scheme: light dark; }
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
"Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
background: #fff; color: #1f2328;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
body { background: #141414; color: #e6e6e6; }
|
|
.hint { color: #8b949e !important; }
|
|
.bar { background: #262626 !important; }
|
|
}
|
|
.card { width: min(420px, calc(100vw - 48px)); text-align: center; padding: 24px; }
|
|
.spinner { width: 44px; height: 44px; animation: rotate 1.6s linear infinite; }
|
|
.spinner circle {
|
|
stroke: #1677ff; stroke-linecap: round;
|
|
animation: dash 1.4s ease-in-out infinite;
|
|
}
|
|
@keyframes rotate { 100% { transform: rotate(360deg); } }
|
|
@keyframes dash {
|
|
0% { stroke-dasharray: 1, 150; stroke-dashoffset: 0; }
|
|
50% { stroke-dasharray: 90, 150; stroke-dashoffset: -24; }
|
|
100% { stroke-dasharray: 90, 150; stroke-dashoffset: -124; }
|
|
}
|
|
h1 { font-size: 17px; font-weight: 600; margin: 20px 0 8px; }
|
|
.hint { font-size: 13px; color: #656d76; margin: 0; }
|
|
.bar { margin-top: 22px; height: 3px; border-radius: 3px; background: #f0f0f0; overflow: hidden; }
|
|
.bar span {
|
|
display: block; height: 100%; width: 35%; border-radius: 3px; background: #1677ff;
|
|
animation: slide 1.5s ease-in-out infinite;
|
|
}
|
|
@keyframes slide {
|
|
0% { transform: translateX(-100%); }
|
|
100% { transform: translateX(340%); }
|
|
}
|
|
.slow { margin-top: 18px; font-size: 12.5px; color: #656d76; display: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="card">
|
|
${SPINNER}
|
|
<h1>Waking the demo up</h1>
|
|
<p class="hint">This instance sleeps when nobody is using it. First request takes a few seconds.</p>
|
|
<div class="bar"><span></span></div>
|
|
<p class="slow" id="slow">Still starting. This can take up to a minute after a new deploy.</p>
|
|
<p class="slow" id="stuck">
|
|
The demo keeps restarting rather than settling. Reloading has been stopped
|
|
so this page does not loop; use the button below to try again.
|
|
<br><br>
|
|
<button type="button" id="retry">Try again</button>
|
|
</p>
|
|
</main>
|
|
<script>
|
|
(function () {
|
|
var delay = 700;
|
|
|
|
// Reloading on ready is what gets the visitor into the app, but a container
|
|
// that flaps would have this page reload endlessly. Count reloads across
|
|
// navigations and stop after a few, so the worst case is a stalled page with
|
|
// an explanation rather than a loop nobody can read or escape.
|
|
var KEY = 'nginx-ui-demo-reloads';
|
|
var MAX_RELOADS = 3;
|
|
// Only reloads bunched together indicate a flap. Spaced-out ones are just
|
|
// ordinary cold starts across a long session and must not accumulate into a
|
|
// false positive.
|
|
var WINDOW_MS = 120000;
|
|
|
|
function readState() {
|
|
try {
|
|
var raw = JSON.parse(sessionStorage.getItem(KEY) || '{}');
|
|
if (!raw || typeof raw.count !== 'number') return { count: 0, at: 0 };
|
|
if (Date.now() - (raw.at || 0) > WINDOW_MS) return { count: 0, at: 0 };
|
|
return raw;
|
|
} catch (e) { return { count: 0, at: 0 }; }
|
|
}
|
|
|
|
function reloadCount() {
|
|
return readState().count;
|
|
}
|
|
|
|
function noteReload() {
|
|
try {
|
|
sessionStorage.setItem(KEY, JSON.stringify({ count: reloadCount() + 1, at: Date.now() }));
|
|
} catch (e) {}
|
|
}
|
|
|
|
function giveUp() {
|
|
var el = document.getElementById('stuck');
|
|
if (el) el.style.display = 'block';
|
|
}
|
|
|
|
var retry = document.getElementById('retry');
|
|
if (retry) {
|
|
retry.addEventListener('click', function () {
|
|
try { sessionStorage.removeItem(KEY); } catch (e) {}
|
|
location.reload();
|
|
});
|
|
}
|
|
|
|
setTimeout(function () {
|
|
var el = document.getElementById('slow');
|
|
if (el) el.style.display = 'block';
|
|
}, 12000);
|
|
|
|
function poll() {
|
|
fetch('/__demo/status', { cache: 'no-store' })
|
|
.then(function (r) { return r.ok ? r.json() : { ready: false }; })
|
|
.then(function (s) {
|
|
if (s && s.ready) {
|
|
if (reloadCount() >= MAX_RELOADS) { giveUp(); return; }
|
|
noteReload();
|
|
// Reload rather than navigate, so the deep link the visitor arrived
|
|
// on is preserved.
|
|
location.reload();
|
|
return;
|
|
}
|
|
schedule();
|
|
})
|
|
.catch(schedule);
|
|
}
|
|
|
|
function schedule() {
|
|
// Back off gently, capped, so a long boot does not hammer the edge.
|
|
delay = Math.min(delay * 1.3, 4000);
|
|
setTimeout(poll, delay);
|
|
}
|
|
|
|
setTimeout(poll, delay);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
}
|