feat(ui): in-session maintenance overlay on the edge 503 marker
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The edge caddy 503 carries X-Scrabble-Maintenance during a deploy window, but a user already in the running SPA only sees their calls start failing — the static caddy page catches only a fresh load. Detect that marker (strictly — not any transient 'unavailable', which the Connecting indicator already covers) on the raw ConnectError at the two transport catch sites (unary exec + the live subscribe stream), before toGatewayError discards the response headers, and raise a non-dismissable dimmed overlay that mirrors the static caddy page. It self-clears: a capped-backoff poll (a cheap read, mirroring connection.svelte.ts) lifts it on the first success, and a manual "retry" button forces an immediate re-check — so it can never get stuck. On detection the read retry loop fails fast, so a window doesn't burn the retry budget on every call. - pure detector maintenance.ts (maintenanceRetryMs / parseRetryAfterMs) + unit tests; the store + self-clearing poll in maintenance.svelte.ts (mirrors connection.svelte.ts) - MaintenanceOverlay.svelte (clones Splash's fixed/inset/dimmed shell, non-dismissable), mounted app-global in App.svelte after Coachmark - transport.ts detects + reports at both catch sites, clears on any successful read - i18n RU "Технические работы" / EN "Under maintenance"; a window.__maint mock hook (the mock can't emit a real 503) + a Playwright spec Prod is same-origin (VITE_GATEWAY_URL empty) so the marker header is readable without a CORS expose-header. Verified: pnpm check (0), unit (402), build, e2e (186, Chromium+WebKit).
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// Global maintenance signal + self-clearing poll. `active` is true while the edge is in a
|
||||
// planned deploy window (a caddy 503 carried `X-Scrabble-Maintenance`; see maintenance.ts).
|
||||
// A non-dismissable overlay (MaintenanceOverlay.svelte) covers the app while active, and a
|
||||
// cheap read is retried on a capped-backoff cadence (mirroring connection.svelte.ts) — the
|
||||
// first success lifts the overlay, so it can never get stuck even with no other traffic.
|
||||
// Mock mode never reports maintenance, so it simply stays inactive.
|
||||
|
||||
import { backoffMs } from './retry';
|
||||
|
||||
let active = $state(false);
|
||||
let retryHintMs = $state(0);
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let attempt = 0;
|
||||
let probing = false;
|
||||
let probe: (() => Promise<void>) | null = null;
|
||||
|
||||
export const maintenance = {
|
||||
/** active is true while the edge is in a planned maintenance window. */
|
||||
get active(): boolean {
|
||||
return active;
|
||||
},
|
||||
/** retryHintMs is the last Retry-After hint in milliseconds (0 when unknown). */
|
||||
get retryHintMs(): number {
|
||||
return retryHintMs;
|
||||
},
|
||||
};
|
||||
|
||||
/** registerMaintenanceProbe installs the reachability read the poll fires while active
|
||||
* (the transport wires a cheap authenticated read). */
|
||||
export function registerMaintenanceProbe(fn: () => Promise<void>): void {
|
||||
probe = fn;
|
||||
}
|
||||
|
||||
/** reportMaintenance raises the overlay and starts the self-clearing poll. Idempotent: a
|
||||
* repeat hit only refreshes the hint, it never restarts a running poll. */
|
||||
export function reportMaintenance(retryAfterMs: number): void {
|
||||
retryHintMs = retryAfterMs;
|
||||
if (active) return;
|
||||
active = true;
|
||||
attempt = 0;
|
||||
if (probe) schedulePoll();
|
||||
}
|
||||
|
||||
/** clearMaintenance lowers the overlay and stops the poll (a successful read, or reset). */
|
||||
export function clearMaintenance(): void {
|
||||
active = false;
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** retryNow forces an immediate re-check (the overlay's button). It never closes the
|
||||
* overlay itself — only a successful probe does. A no-op while a probe is in flight. */
|
||||
export function retryNow(): void {
|
||||
if (!active || !probe || probing) return;
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
attempt = 0;
|
||||
runProbe();
|
||||
}
|
||||
|
||||
function schedulePoll(): void {
|
||||
pollTimer = setTimeout(runProbe, backoffMs(++attempt));
|
||||
}
|
||||
|
||||
function runProbe(): void {
|
||||
pollTimer = null;
|
||||
if (!active || !probe || probing) return;
|
||||
probing = true;
|
||||
probe().then(
|
||||
() => {
|
||||
probing = false;
|
||||
clearMaintenance();
|
||||
},
|
||||
() => {
|
||||
probing = false;
|
||||
if (active) schedulePoll();
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user