From ee1c6de016f4c9663b2e4a4cf99eeaec0056dbf4 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 30 Sep 2026 09:06:49 +0000 Subject: [PATCH] Keep domain setup polling through gateway restarts --- apps/web/src/features/system/DomainPage.tsx | 7 ++-- .../src/features/system/domain-api.test.ts | 35 +++++++++++++++++-- apps/web/src/features/system/domain-api.ts | 16 ++++++++- apps/web/src/i18n/locales/en/system.ts | 2 +- apps/web/src/i18n/locales/zh-CN/system.ts | 2 +- docs/web/console-server.md | 2 +- 6 files changed, 55 insertions(+), 9 deletions(-) diff --git a/apps/web/src/features/system/DomainPage.tsx b/apps/web/src/features/system/DomainPage.tsx index d525ac830..18f70b345 100644 --- a/apps/web/src/features/system/DomainPage.tsx +++ b/apps/web/src/features/system/DomainPage.tsx @@ -8,7 +8,7 @@ import { ErrorState } from "../../components/ErrorState"; import { TableSkeleton } from "../../components/Skeleton"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { useConsoleAccount } from "../first-run/ConsoleAccess"; -import { applyDomain, domainHostname, domainInProgress, domainQuery, DomainRequestError, type DomainStatus } from "./domain-api"; +import { applyDomain, domainHostname, domainInProgress, domainQuery, domainReconnecting, DomainRequestError, type DomainStatus } from "./domain-api"; import { DomainHandoff } from "./DomainHandoff"; import "./domain.css"; @@ -59,6 +59,7 @@ function DomainForm({ initial }: { initial: DomainStatus }) { const host = domainHostname(hostname); const active = domainInProgress(snapshot); const blocked = busy || active || uncertain || query.isError || query.isFetching || !snapshot.supported; + const failed = query.isError && !domainReconnecting(query); const reconnect = uncertain || busy ? attemptedUrl : snapshot.state === "ready" ? snapshot.target_url ?? snapshot.public_url : (active || query.isError) ? snapshot.target_url : null; useEffect(() => { if (reconnect) setDomainHandoff?.(reconnect); @@ -127,9 +128,9 @@ function DomainForm({ initial }: { initial: DomainStatus }) { void refresh()} /> - {(active || snapshot.state === "ready") && !query.isError && !busy && !uncertain ?

{t(`domain.states.${snapshot.state}`)}

: null} + {(active || snapshot.state === "ready") && !failed && !busy && !uncertain ?

{t(`domain.states.${snapshot.state}`)}

: null} {snapshot.state === "failed" ?

{t("domain.failed")} {snapshot.message ? {snapshot.message} : null}

: null} - {query.isError ?

{t(active ? "domain.disconnected" : "domain.loadFailed")}

: null} + {failed ?

{t(active ? "domain.disconnected" : "domain.loadFailed")}

: null} {error && !confirmation ?

{error.text} {error.detail ? {error.detail} : null}

: null} } diff --git a/apps/web/src/features/system/domain-api.test.ts b/apps/web/src/features/system/domain-api.test.ts index acbea5685..d4bee11f6 100644 --- a/apps/web/src/features/system/domain-api.test.ts +++ b/apps/web/src/features/system/domain-api.test.ts @@ -1,7 +1,11 @@ +// QueryObserver enables browser polling only when window exists at module load. +vi.hoisted(() => vi.stubGlobal("window", {})); + +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { applyDomain, domainHostname, domainTarget, DomainRequestError } from "./domain-api"; +import { applyDomain, DOMAIN_RECONNECT_GRACE_MS, domainHostname, domainQuery, domainReconnecting, domainTarget, DomainRequestError } from "./domain-api"; -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); describe("domain navigation and writes", () => { it("accepts DNS names while rejecting URLs, IPs and path-like input", () => { @@ -28,4 +32,31 @@ describe("domain navigation and writes", () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ supported: true, state: "ready", public_url: null, target_url: "https://evil.example.com/path", message: null })))); await expect(applyDomain("core.example.com", false, new AbortController().signal)).rejects.toBeInstanceOf(DomainRequestError); }); + it("keeps polling setup while the gateway restarts and reports a disconnect only after the grace period", async () => { + vi.useFakeTimers(); + const status = (state: string) => new Response(JSON.stringify({ supported: true, state, public_url: null, target_url: "https://core.example.com", message: null })); + const fetch = vi.fn().mockResolvedValueOnce(status("checking")).mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockImplementation(() => Promise.resolve(new Response(null, { status: 502 }))); + vi.stubGlobal("fetch", fetch); + const client = new QueryClient(); + const observer = new QueryObserver(client, domainQuery); + const unsubscribe = observer.subscribe(() => undefined); + try { + await vi.advanceTimersByTimeAsync(2_000); + expect(observer.getCurrentResult().isError).toBe(true); + expect(domainReconnecting(observer.getCurrentResult())).toBe(true); + await vi.advanceTimersByTimeAsync(DOMAIN_RECONNECT_GRACE_MS - 4_000); + expect(domainReconnecting(observer.getCurrentResult())).toBe(true); + await vi.advanceTimersByTimeAsync(2_000); + expect(observer.getCurrentResult().isError).toBe(true); + expect(domainReconnecting(observer.getCurrentResult())).toBe(false); + fetch.mockImplementation(() => Promise.resolve(status("ready"))); + await vi.advanceTimersByTimeAsync(2_000); + expect(observer.getCurrentResult()).toMatchObject({ isError: false, data: { state: "ready" } }); + const calls = fetch.mock.calls.length; + await vi.advanceTimersByTimeAsync(10_000); + expect(fetch).toHaveBeenCalledTimes(calls); + expect(fetch.mock.calls.every(([, init]) => init.method === undefined)).toBe(true); + } finally { unsubscribe(); client.clear(); } + }); }); diff --git a/apps/web/src/features/system/domain-api.ts b/apps/web/src/features/system/domain-api.ts index 8d6a3d152..b4a81ee40 100644 --- a/apps/web/src/features/system/domain-api.ts +++ b/apps/web/src/features/system/domain-api.ts @@ -57,11 +57,25 @@ async function domainRequest(init: RequestInit): Promise { return parseStatus(await response.json()); } +/** Setup restarts the gateway, which briefly drops the console's own connection; polls ride that out before reporting a disconnect. */ +export const DOMAIN_RECONNECT_GRACE_MS = 30_000; + +/** No response, or a proxy error while the gateway restarts. */ +function connectionLost(error: unknown): boolean { + return !(error instanceof DomainRequestError) || error.status === 502 || error.status === 503 || error.status === 504; +} + +/** A lost connection during setup, before the grace period since the last status has passed. */ +export function domainReconnecting(query: { data: DomainStatus | undefined; error: unknown; dataUpdatedAt: number; errorUpdatedAt: number }): boolean { + return query.error != null && domainInProgress(query.data) && connectionLost(query.error) && + query.errorUpdatedAt - query.dataUpdatedAt < DOMAIN_RECONNECT_GRACE_MS; +} + export const domainQuery = queryOptions({ queryKey: ["console-domain"], queryFn: ({ signal }) => domainRequest({ signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]) }), retry: false, - refetchInterval: (query) => domainInProgress(query.state.data) && !query.state.error ? 2_000 : false, + refetchInterval: (query) => domainInProgress(query.state.data) && (!query.state.error || connectionLost(query.state.error)) ? 2_000 : false, }); /** One same-origin write per user action; never retries a possibly accepted apply. */ diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index f50c8ca25..66f18a9ca 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -15,7 +15,7 @@ export const system = { "hostnameHelp": "Enter a domain without https://, a port or a path.", "invalidHostname": "Enter a domain such as core.example.com.", "automatic": "Automatic HTTPS", - "dnsHelp": "Point the domain’s A or AAAA record to this server. Ports 80 and 443 must be open for certificate verification and HTTPS access.", + "dnsHelp": "Point the domain’s A or AAAA record to this server. For certificate verification and HTTPS access, ports 80 and 443 must be reachable from the internet and not used by another program on the server.", "prerequisites": "Point your domain to this server before applying.", "submitting": "Applying…", "retry": "Retry setup", diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index d4f707edb..e3a71b5fd 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -17,7 +17,7 @@ export const system: TranslationShape = { "hostnameHelp": "只填写域名,不包含 https://、端口或路径。", "invalidHostname": "请填写有效域名,例如 core.example.com。", "automatic": "自动配置 HTTPS", - "dnsHelp": "将域名的 A 或 AAAA 记录指向这台服务器,并开放 80 和 443 端口,用于证书验证和 HTTPS 访问。", + "dnsHelp": "将域名的 A 或 AAAA 记录指向这台服务器。80 和 443 端口用于证书验证和 HTTPS 访问,需能从公网访问,且不能被服务器上的其他程序占用。", "prerequisites": "应用前,请先将域名解析到这台服务器。", "submitting": "正在应用…", "retry": "重试配置", diff --git a/docs/web/console-server.md b/docs/web/console-server.md index c960c20e0..b4c5165b7 100644 --- a/docs/web/console-server.md +++ b/docs/web/console-server.md @@ -106,7 +106,7 @@ The status has `supported`, `state` (`unconfigured`, `checking`, `applying`, `re Without `OAC_WEB_INSTALLATION_SOCKET` (external reverse proxy installations), `GET` reports `supported: false` and `POST` returns 400 `domain_setup_unavailable`. An unreachable installer or an invalid answer returns 502 `installation_unreachable`. -The System page submits a hostname once, polls the status every 2 seconds while it is `checking` or `applying`, and asks for confirmation when the installer requires it. It never retries a write. Applying the domain restarts the console, which ends every session; the page keeps a sign-in link to the new HTTPS address. Only the `ready` state confirms HTTPS; the browser does not probe the new origin. The installer owns certificates, locking and recovery ([managed HTTPS](../../deploy/install/README.md#managed-https)). +The System page submits a hostname once, polls the status every 2 seconds while it is `checking` or `applying`, and asks for confirmation when the installer requires it. During setup, network failures and HTTP 502/503/504 responses keep polling active. The page allows 30 seconds without a successful status response before showing the disconnected message, and recovers when a poll succeeds. It never retries a write. Applying the domain restarts the console, which ends every session; the page keeps a sign-in link to the new HTTPS address. Only the `ready` state confirms HTTPS; the browser does not probe the new origin. The installer owns certificates, locking and recovery ([managed HTTPS](../../deploy/install/README.md#managed-https)). `OAC_WEB_BOOTSTRAP=1`, which the installer sets while no public URL is configured, lets the console also accept plain HTTP requests addressed to a literal IP address, treating `http://` as the origin, so an operator can sign in through the server's IP address. Host names still require `OAC_WEB_ORIGIN`, so DNS rebinding cannot reach the console.