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
7 changes: 4 additions & 3 deletions apps/web/src/features/system/DomainPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -127,9 +128,9 @@ function DomainForm({ initial }: { initial: DomainStatus }) {
<RefreshButton label={t("domain.refresh")} refreshing={query.isFetching} disabled={busy} onClick={() => void refresh()} />
</div>
</form>
{(active || snapshot.state === "ready") && !query.isError && !busy && !uncertain ? <p className="domain-state" role="status">{t(`domain.states.${snapshot.state}`)}</p> : null}
{(active || snapshot.state === "ready") && !failed && !busy && !uncertain ? <p className="domain-state" role="status">{t(`domain.states.${snapshot.state}`)}</p> : null}
{snapshot.state === "failed" ? <p className="domain-error" role="alert">{t("domain.failed")} {snapshot.message ? <HelpTip label={t("domain.details")}>{snapshot.message}</HelpTip> : null}</p> : null}
{query.isError ? <p className="domain-error" role="alert">{t(active ? "domain.disconnected" : "domain.loadFailed")}</p> : null}
{failed ? <p className="domain-error" role="alert">{t(active ? "domain.disconnected" : "domain.loadFailed")}</p> : null}
{error && !confirmation ? <p className="domain-error" role="alert">{error.text} {error.detail ? <HelpTip label={t("domain.details")}>{error.detail}</HelpTip> : null}</p> : null}
<DomainHandoff url={reconnect} />
</>}
Expand Down
35 changes: 33 additions & 2 deletions apps/web/src/features/system/domain-api.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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(); }
});
});
16 changes: 15 additions & 1 deletion apps/web/src/features/system/domain-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,25 @@ async function domainRequest(init: RequestInit): Promise<DomainStatus> {
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. */
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/i18n/locales/en/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/i18n/locales/zh-CN/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const system: TranslationShape<typeof english> = {
"hostnameHelp": "只填写域名,不包含 https://、端口或路径。",
"invalidHostname": "请填写有效域名,例如 core.example.com。",
"automatic": "自动配置 HTTPS",
"dnsHelp": "将域名的 A 或 AAAA 记录指向这台服务器,并开放 80 和 443 端口,用于证书验证和 HTTPS 访问。",
"dnsHelp": "将域名的 A 或 AAAA 记录指向这台服务器。80 和 443 端口用于证书验证和 HTTPS 访问,需能从公网访问,且不能被服务器上的其他程序占用。",
"prerequisites": "应用前,请先将域名解析到这台服务器。",
"submitting": "正在应用…",
"retry": "重试配置",
Expand Down
2 changes: 1 addition & 1 deletion docs/web/console-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<that address>` 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.

Expand Down