diff --git a/devlog/_plan/260912_operations/050_pairing.md b/devlog/_plan/260912_operations/050_pairing.md index 2b05bff2ba..dacb098a50 100644 --- a/devlog/_plan/260912_operations/050_pairing.md +++ b/devlog/_plan/260912_operations/050_pairing.md @@ -4,10 +4,28 @@ Class C3; dependency roadmap. Reuse existing connected-client state and browser- MODIFY owning dashboard pending-auth component and bootstrap state: distinguish a reachable connected machine awaiting hub browser authentication from a stopped standalone proxy. Show configured hub identity/origin, explain that machine enrollment and browser session are separate, offer the current origin-specific existing pairing/authentication action. Preserve revoked/expired/unreachable states and their existing retry actions; do not suggest ocx start while the local runtime is reachable. Derive the next action from current origin + configured hub instead of a hardcoded localhost URL. No credentials appear in visible copy/URLs. -MODIFY all gui/src/i18n locale dictionaries with meaningful labels. Extend existing pending-auth/dashboard tests for local origin, remote hub origin, pending, authenticated, expired/revoked and unavailable standalone; positive browser auth transitions into connected dashboard. Exact files: gui/src/App.tsx, api.ts, pages/dashboard-core-poll.ts, pages/use-dashboard-data.ts and pages/Dashboard.tsx consume a classified authentication/error state instead of a boolean. Existing connect-pairing.ts and connect-pairing-transport.ts own hub identity and origin-specific action. Define the error classification in api.ts at response ingress; consume in polling and Dashboard; reset on authenticated success and pairing completion. No persistence/serialization for this UI state. Keep cached data with stale labeling when auth fails; do not erase a known hub into standalone offline. Public hub/browser-pairing guidance is updated with the same distinction. No service restart or live auth reconfiguration. +MODIFY all gui/src/i18n locale dictionaries with meaningful labels. Extend existing pending-auth/dashboard tests for local origin, remote hub origin, pending, authenticated, expired/revoked and unavailable standalone; positive browser auth transitions into connected dashboard. Exact files: gui/src/App.tsx, api.ts, pages/dashboard-core-poll.ts, pages/use-dashboard-data.ts and pages/Dashboard.tsx consume a classified authentication/error state instead of a boolean. Existing connect-pairing.tsx and connect-pairing-transport.ts own hub identity and origin-specific action. Define the error classification in api.ts at response ingress; consume in polling and Dashboard; reset on authenticated success and pairing completion. No persistence/serialization for this UI state. Keep cached data with stale labeling on non-auth read failures; hide it when authentication or permission is denied; do not erase a known hub into standalone offline. Public hub/browser-pairing guidance is updated with the same distinction. No service restart or live auth reconfiguration. Hosted component suite and screenshot artifact of the rendered pending state required for final delivery; local GUI tests/build NOT RUN. Static source or mockup is not rendered application evidence. Accepted OPS-PAIR-01/02. Cases include browser session expiry and post-pairing refresh, local and hub origin guidance, code versus API/admin-key explanation, and operator handoff text. Prefer existing component tests; new test files only where needed. Reflection amendments: reuse existing api.ts SESSION_UNAVAILABLE_EVENT and App sharedSessionReady; subscribe in App, emit on terminal 401 expiry (not aborted requests), reset/read refresh on successful pairing. Do not create duplicate auth state. Poll classification and pairing errors distinguish HTTP auth refusal, transport/network, and invalid responses; aborted work does not show a failure. + +P revalidation at81f0c78d7a: same App, Dashboard, pairing and API owners remain. This independent branch starts from refresheddev; previous usage-transportD directs pairing after the user-requested source repairs. + +Concrete delta: App subscribes to SESSION_UNAVAILABLE_EVENT for the shared plane and derives readiness from hasApiSession; ignore a late notice while a newer session is present. Pairing success increments a dashboard refresh epoch and marks ready. Pass connected/authenticationPending into Dashboard. Hide protected dashboard content while authentication is pending; keep known data with a stale notice only on non-auth read failures. In fetchDashboardOverview distinguish 401(auth), 403(denied), other non-OK(request), invalid JSON/shape(invalid), and transport failure(unavailable); aborted polls propagate without publishing an error. Hook exposes failure and overview refresh without a second authentication store. Only standalone transport unavailability may show ocx start; connected/auth/invalid/request failures use relevant copy and retry. + +API wrapper emits its existing unavailable event on terminal401 only when the caller is not aborted and no newer session exists. Retain credential refresh/singleflight behavior; no new auth bypass or token persistence. Dashboard receives success epoch as a prop; useDashboardData adds it to existing useKeyedClientResource revalidation dependencies without changing resource keys or remounting. Every dashboard resource refreshes even when a settled failed/cached store survived. + +Pairing form shows target.serverOrigin, a copyable ocx gui pair --origin command for window.location.origin, instructions to run it on the hub or ask its operator, and the distinction between one-time code and API/admin keys. Reuse useCopyFeedback and existing copy labels; copy failure remains visible. Keep relay technical copy subordinate. Pairing transport gets a typed error kind (invalid-code/refused/unreachable/invalid-response), mapped to localized actionable copy while preserving pasted code; abort does not publish an error. This is process-local UI state, not a wire schema. + +Exact regressions: extend gui/tests/connect-pairing.test.ts for real App dashboard pending/authsuccess/expiry/recovery and hub/command identity; extend api-auth-deadline.test.ts for terminal notice behavior if needed; NEW gui/tests/dashboard-connection-state.test.ts for poll failure classes, cached data and no erroneous start advice. All9 locale modules get new copy. Existing Notice/buttons/tokens, variance2/motion1, dense utility layout; no decorative assets or new dependencies. Hosted built preview, inspected screenshot and browser interaction supply rendered proof later; local suites/build NOT RUN. + +Pairing lifetime precision: form keyed by target server/bootstrap identity, one AbortController per submit cancelled on unmount; transport accepts optional caller signal in addition to its existing fetch seam and checks abort before session installation. This prevents an obsolete target response from installing a session or publishing errors after its form unmounts. Keep existing request method/credential mechanics unchanged. + +A amendment: post-pairing refresh explicitly reaches each dashboard keyed resource through [apiBase, refreshEpoch] dependencies; a component remount is not treated as a cache invalidation mechanism. Regression first seeds a failed overview store, completes pairing, and requires a new authenticated health/provider read plus rendered data. + +Reflection03/05 closure:403 keeps distinct permission-denied guidance and never starts or re-pairs a running proxy merely for denied permissions. Validate HealthData status/version strings and finite nonnegative uptime; providers must be an array of objects with the required name/adapter/baseUrl strings and hasApiKey boolean, optional defaultModel string. Invalid shapes are classified invalid even with HTTP200. Unauthorized/denied content stays hidden; only nonauth read failure may show cached data with stale notice. + +Resume C repairs: hosted34682559994 found erasableSyntaxOnly constructor parameter-property and React ref analysis at createElement form. Explicit class field and JSX component preserve behavior without disabling rules. Source compound-failure repair was already published externally at4124a644; local byte-identical patch preserved before fast-forward. Hidden-document App fixture disables periodic polls, and a controlled real resource deadline verifies retained data becomes stale. All local suites/build/typecheck/install NOT RUN. diff --git a/devlog/_plan/260912_operations/110_resume_status.md b/devlog/_plan/260912_operations/110_resume_status.md new file mode 100644 index 0000000000..2ae5182e88 --- /dev/null +++ b/devlog/_plan/260912_operations/110_resume_status.md @@ -0,0 +1,9 @@ +# Operations resume checkpoint + +Update carry #4343 merged with exact candidate f37894711158fa8215d26bed642389148ac395f6 and successful Cross-platform CI34674523305. The already-carried stop fix is not replayed. Original issue/PR closure stays with integration coordination. + +Listener #4353, usage #4357 → #4373 and pairing #4378 remain open. Published usage source/security audits passed; hosted execution must be checked on the final cumulative tip. Prior Cline registration/native-restore fixture failures are retained as failures; current dev has a separate repair, and this unit does not duplicate its ownership. + +Pairing resumed at persisted C. The previous local compound-failure patch matched the newer remote commit byte-for-byte and was preserved before fast-forward. The subsequent fix uses an erasable explicit error field and JSX event handlers for the hosted compiler/lint failures; no checks were disabled. A hidden-document pairing fixture excludes periodic polling, and a controlled resource deadline verifies stale-data marking. Hosted execution and rendered preview remain pending. + +The OpenCode management-token and local transport change remains outstanding against original #4317 CHANGES_REQUESTED. No whole-lane completion is claimed. All local suites, focused tests, GUI tests, build, typecheck and installs are NOT RUN. No service changes or release actions were performed. diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 0db5e7bcd5..d7c257e41d 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -722,3 +722,9 @@ For a service rollback, stop the branch service and repair the prior release aga session, not a client data key. - **Outstanding revocation after disconnect:** use the hub dashboard's **Integrations → API Keys** page. It is the sole post-disconnect revocation path. + +### Pairing this browser with a hub + +Machine enrollment and browser authentication are separate. The pairing panel names the hub and displays an `ocx gui pair --origin` command for the exact origin currently open in your browser. Run that command on the hub, or send it to the hub operator and request a one-time pairing code. Paste that code into the panel; a data API key or admin token is not a pairing code. + +While browser authentication is pending, the dashboard does not recommend restarting a healthy connected client. Completing pairing refreshes the dashboard data immediately, including a previously cached authentication failure. Session expiry returns to pairing; permission denial keeps its own access-settings guidance. Other failed refreshes may show the last received data with a stale-data notice and retry action. diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 6550f404fe..67361c52fa 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -338,3 +338,9 @@ Adding **Ollama Cloud** or another catalog provider from the dashboard copies it classification into the saved provider config, so the [vision sidecar](/guides/sidecars/) is gated correctly without manual classification. ::: + +### Pairing this browser with a hub + +Machine enrollment and browser authentication are separate. The pairing panel names the hub and displays an `ocx gui pair --origin` command for the exact origin currently open in your browser. Run that command on the hub, or send it to the hub operator and request a one-time pairing code. Paste that code into the panel; a data API key or admin token is not a pairing code. + +While browser authentication is pending, the dashboard does not recommend restarting a healthy connected client. Completing pairing refreshes the dashboard data immediately, including a previously cached authentication failure. Session expiry returns to pairing; permission denial keeps its own access-settings guidance. Other failed refreshes may show the last received data with a stale-data notice and retry action. diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index c46bb91b18..a48b53df2c 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -336,3 +336,9 @@ docker compose up -d - `/v1/catalog`가 `403 origin_rejected`인데 `/readyz`가 `200`이면 데이터 리스너가 TLS 프런트엔드 뒤에서 루프백에 바인드되어 있습니다. [데이터 리스너에 TLS 붙이기](#데이터-리스너에-tls-붙이기)를 보세요. - 브라우저 로그아웃/만료는 해당 원격 세션만 끊습니다. 데이터 키와는 별개입니다. - 연결 해제 후 남은 키는 허브의 **Integrations → API Keys**에서만 폐기할 수 있습니다. + +### 이 브라우저를 허브에 인증하기 + +기기 연결과 브라우저 인증은 별개입니다. 페어링 패널에 표시된 허브에서 현재 브라우저 주소용 `ocx gui pair --origin` 명령을 실행하세요. 직접 운영하지 않는 허브라면 운영자에게 명령을 전달하고 일회용 코드를 요청하세요. 입력 칸에는 페어링 코드를 붙여 넣습니다. 데이터 API 키나 관리자 토큰을 대신 입력하지 마세요. + +인증을 기다리는 동안 정상인 클라이언트를 재시작하라고 안내하지 않습니다. 페어링을 마치면 이전 인증 오류가 캐시에 남아 있어도 대시보드를 새로 읽습니다. 세션이 만료되면 페어링 화면으로 돌아가며, 권한 거부는 별도로 안내합니다. 다른 갱신 오류에서는 마지막 데이터를 오래된 정보로 표시하고 재시도할 수 있습니다. diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 4ae2762b1a..f07c387385 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -229,3 +229,9 @@ GUI에서 OAuth 계정을 선택하면 풀 모드에서도 다음 요청에 반 이유만으로 바꾸지 않아요. 선택 계정이 429를 반환하면 풀이 꺼져 있어도 사용 가능한 다른 계정으로 자동 전환해요. 자동 선택이 저장되면 GUI의 활성 표시도 즉시 바뀌어요. 이미 서버로 보낸 요청의 인증 정보는 바꾸지 않아요. + +### 이 브라우저를 허브에 인증하기 + +기기 연결과 브라우저 인증은 별개입니다. 페어링 패널에 표시된 허브에서 현재 브라우저 주소용 `ocx gui pair --origin` 명령을 실행하세요. 직접 운영하지 않는 허브라면 운영자에게 명령을 전달하고 일회용 코드를 요청하세요. 입력 칸에는 페어링 코드를 붙여 넣습니다. 데이터 API 키나 관리자 토큰을 대신 입력하지 마세요. + +인증을 기다리는 동안 정상인 클라이언트를 재시작하라고 안내하지 않습니다. 페어링을 마치면 이전 인증 오류가 캐시에 남아 있어도 대시보드를 새로 읽습니다. 세션이 만료되면 페어링 화면으로 돌아가며, 권한 거부는 별도로 안내합니다. 다른 갱신 오류에서는 마지막 데이터를 오래된 정보로 표시하고 재시도할 수 있습니다. diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 91890ce664..6b2e1d4c32 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -15,7 +15,7 @@ import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons"; import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; import { Select } from "./ui"; -import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession } from "./api"; +import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, SESSION_UNAVAILABLE_EVENT } from "./api"; import { apiBaseForPlane, discoverApiTargets, isConnectedRuntime, standaloneApiTargets, type ApiTargets } from "./api-targets"; import { ConnectPairingForm } from "./connect-pairing"; import { type Page } from "./app-routing"; @@ -111,8 +111,19 @@ export default function App() { const [targetsSettled, setTargetsSettled] = useState(() => !isConnectedRuntime()); const [targetError, setTargetError] = useState(false); const [sharedSessionReady, setSharedSessionReady] = useState(() => hasApiSession("shared")); + const [sharedSessionEpoch, setSharedSessionEpoch] = useState(0); const [sessionLoggingOut, setSessionLoggingOut] = useState(false); + useEffect(() => { + const unavailable = (event: Event) => { + if ((event as CustomEvent<{ plane?: string }>).detail?.plane === "shared" && !hasApiSession("shared")) { + setSharedSessionReady(false); + } + }; + window.addEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); + return () => window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); + }, []); + useEffect(() => { const controller = new AbortController(); void discoverApiTargets(API_BASE, controller.signal).then(async next => { @@ -422,9 +433,13 @@ export default function App() {
{t("connection.machineUnavailable")}
)} {targets.connected && !sharedSessionReady && ( - setSharedSessionReady(true)} /> + { + setSharedSessionReady(true); + setSharedSessionEpoch(epoch => epoch + 1); + }} /> )} - {page === "dashboard" && } + {page === "dashboard" && } {page === "startup" && } {page === "providers" && } {page === "models" && } diff --git a/gui/src/api.ts b/gui/src/api.ts index 020183dd47..1d51174f9b 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -3,9 +3,8 @@ import { createBoundedFetch } from "./bounded-fetch"; import { adminTokenPromptAllowed, standaloneApiTargets, type ApiPlane, type ApiTarget, type ApiTargets } from "./api-targets"; /** - * Fired instead of the admin-token prompt when the dashboard cannot start a session on a - * deployment that has no admin token to type. The shell renders it as a notice; nothing - * blocks on it. + * Fired after an unauthorized request cannot recover a session. The shell synchronizes + * its existing readiness state; cancelled callers and newer valid sessions emit no notice. */ export const SESSION_UNAVAILABLE_EVENT = "opencodex:session-unavailable"; @@ -282,7 +281,6 @@ async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null, // of a password box the user cannot answer (#3353, #3483). if (!adminTokenPromptAllowed()) { state.promptCancelled = true; - reportSessionUnavailable(plane); return null; } const prompted = await requestAdminToken(token => verifyAdminToken(plane, token)); @@ -332,10 +330,16 @@ export function installApiAuthFetch(): void { } else clearSessionIfCurrent(classified.plane, token); const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined); const nextToken = await resolveTokenAfter401(classified.plane, token, callerSignal ?? undefined); - if (!nextToken) return response; + if (!nextToken) { + if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionUnavailable(classified.plane); + return response; + } const [retryInput, retryInit] = withAuth(classified.plane, input, init, nextToken); const retry = await originalFetch(retryInput, retryInit); - if (retry.status === 401) clearSessionIfCurrent(classified.plane, nextToken); + if (retry.status === 401) { + clearSessionIfCurrent(classified.plane, nextToken); + if (!callerSignal?.aborted && !hasApiSession(classified.plane)) reportSessionUnavailable(classified.plane); + } return retry; }; } diff --git a/gui/src/connect-pairing-transport.ts b/gui/src/connect-pairing-transport.ts index fc82035085..f83dcf1d75 100644 --- a/gui/src/connect-pairing-transport.ts +++ b/gui/src/connect-pairing-transport.ts @@ -3,6 +3,15 @@ import type { ApiTarget } from "./api-targets"; const PAIRING_CODE = /^ocx_pair_[A-Za-z0-9_-]{43}$/; +export class PairingError extends Error { + readonly kind: "invalid-code" | "refused" | "unreachable" | "request-failed" | "invalid-response"; + constructor(kind: PairingError["kind"]) { + super(`pairing_${kind}`); + this.kind = kind; + this.name = "PairingError"; + } +} + /** * Exchange a pairing code for a shared-plane session. * @@ -15,9 +24,11 @@ export async function submitConnectPairing( target: ApiTarget, grant: string, fetchImpl?: typeof fetch, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const code = grant.trim(); - if (!PAIRING_CODE.test(code)) throw new Error("pairing_code_invalid"); + if (!PAIRING_CODE.test(code)) throw new PairingError("invalid-code"); // Resolved at CALL time, not as a default parameter. // // `installApiAuthFetch` replaces `window.fetch` with the wrapper that attaches plane @@ -26,13 +37,25 @@ export async function submitConnectPairing( // evaluated, which on the relay path is the unwrapped original, so the request went out // unauthenticated and the relay refused it. const send = fetchImpl ?? ((input, init) => window.fetch(input, init)); - const response = await send(target.bootstrapPath, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "text/html" }, - body: JSON.stringify({ grant: code }), - }); - if (!response.ok) throw new Error("pairing_refused"); - const html = await response.text(); - if (!installApiSessionFromHtml("shared", html)) throw new Error("pairing_response_invalid"); + let response: Response; + try { + response = await send(target.bootstrapPath, { + method: "POST", signal, + headers: { "Content-Type": "application/json", Accept: "text/html" }, + body: JSON.stringify({ grant: code }), + }); + } catch (error) { + if (signal?.aborted) throw error; + throw new PairingError("unreachable"); + } + if (!response.ok) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new PairingError(response.status === 401 || response.status === 403 ? "refused" : "request-failed"); + } + let html: string; + try { html = await response.text(); } + catch (error) { if (signal?.aborted) throw error; throw new PairingError("invalid-response"); } + signal?.throwIfAborted(); + if (!installApiSessionFromHtml("shared", html)) throw new PairingError("invalid-response"); return true; } diff --git a/gui/src/connect-pairing.ts b/gui/src/connect-pairing.ts deleted file mode 100644 index 00e48abd7a..0000000000 --- a/gui/src/connect-pairing.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createElement, useState, type ChangeEvent, type FormEvent } from "react"; -import type { ApiTarget } from "./api-targets"; -import { useT } from "./i18n/shared"; -import { submitConnectPairing } from "./connect-pairing-transport"; - -export function ConnectPairingForm({ - target, - onConnected, -}: { - target: ApiTarget; - onConnected: () => void; -}) { - const t = useT(); - const [grant, setGrant] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(false); - - const submit = async (event: FormEvent) => { - event.preventDefault(); - if (busy) return; - setBusy(true); - setError(false); - try { - await submitConnectPairing(target, grant); - onConnected(); - } catch { - setError(true); - } finally { - setBusy(false); - } - }; - - return createElement("section", { className: "card connect-pairing", "aria-labelledby": "connect-pairing-title" }, - createElement("h2", { id: "connect-pairing-title" }, t("connection.pairing.title")), - createElement("p", null, t(target.transport === "relay" ? "connection.pairing.relayWarning" : "connection.pairing.body")), - createElement("form", { onSubmit: submit, className: "api-form-row" }, - createElement("label", { htmlFor: "connect-pairing-code", className: "field-label" }, t("connection.pairing.code")), - createElement("input", { - id: "connect-pairing-code", - name: "pairingCode", - value: grant, - onChange: (event: ChangeEvent) => setGrant(event.currentTarget.value), - autoComplete: "off", - spellCheck: false, - disabled: busy, - className: "input mono", - "aria-invalid": error || undefined, - "aria-describedby": error ? "connect-pairing-error" : undefined, - }), - createElement("button", { type: "submit", className: "btn btn-primary", disabled: busy || !grant.trim() }, - t(busy ? "connection.pairing.submitting" : "connection.pairing.submit")), - error ? createElement("p", { id: "connect-pairing-error", className: "alert alert-err", role: "alert" }, t("connection.pairing.error")) : null, - ), - ); -} diff --git a/gui/src/connect-pairing.tsx b/gui/src/connect-pairing.tsx new file mode 100644 index 0000000000..c6c9750b1a --- /dev/null +++ b/gui/src/connect-pairing.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; +import type { ApiTarget } from "./api-targets"; +import { useT } from "./i18n/shared"; +import { PairingError, submitConnectPairing } from "./connect-pairing-transport"; +import { useCopyFeedback } from "./components/use-copy-feedback"; + +export function ConnectPairingForm({ + target, + onConnected, +}: { + target: ApiTarget; + onConnected: () => void; +}) { + const t = useT(); + const [grant, setGrant] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const activeRequest = useRef(null); + useEffect(() => () => activeRequest.current?.abort(), []); + const copyFeedback = useCopyFeedback(); + const command = `ocx gui pair --origin "${window.location.origin}"`; + const copied = copyFeedback.outcomeFor(command); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + const controller = new AbortController(); + activeRequest.current = controller; + try { + await submitConnectPairing(target, grant, undefined, controller.signal); + if (!controller.signal.aborted) onConnected(); + } catch (failure) { + if (!controller.signal.aborted) setError(failure instanceof PairingError ? failure.kind : "unreachable"); + } finally { + if (!controller.signal.aborted) setBusy(false); + if (activeRequest.current === controller) activeRequest.current = null; + } + }; + + return
+

{t("connection.pairing.title")}

+

{t("connection.pairing.hub")}: {target.serverOrigin}

+

{t("connection.pairing.getCode")}

+
{command}
+ + {copied === "unavailable" &&

{t("prov.linkCopyUnavailable")}

} +

{t("connection.pairing.askOperator")}

+

{t("connection.pairing.notApiKey")}

+
+ + ) => setGrant(event.currentTarget.value)} + autoComplete="off" spellCheck={false} disabled={busy} className="input mono" + aria-invalid={Boolean(error) || undefined} aria-describedby={error ? "connect-pairing-error" : undefined} /> + + {error && } +
+ {target.transport === "relay" &&

{t("connection.pairing.relayWarning")}

} +
; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index db6b8c889f..51dc5d0b7c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2574,6 +2574,17 @@ export const de: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "Hub", + "connection.pairing.getCode": "Führen Sie diesen Befehl für diesen Browser auf dem Hub aus:", + "connection.pairing.askOperator": "Falls jemand anderes den Hub betreibt, senden Sie dieser Person den Befehl und bitten Sie um einen einmaligen Kopplungscode.", + "connection.pairing.notApiKey": "Fügen Sie hier einen einmaligen Kopplungscode ein. Daten-API-Schlüssel und Admin-Token gehören nicht in dieses Feld.", + "connection.pairing.networkError": "Der Hub ist nicht erreichbar. Prüfen Sie die Verbindung und versuchen Sie es erneut; Ihr Code bleibt erhalten.", + "connection.pairing.requestError": "Der Hub konnte die Kopplungsanfrage nicht abschließen. Prüfen Sie seinen Status und versuchen Sie es erneut.", + "connection.pairing.responseError": "Der Hub hat keine gültige Browsersitzung zurückgegeben. Aktualisieren Sie den Hub oder fragen Sie den Betreiber und versuchen Sie es erneut.", + "dash.authRequired": "Zum Anzeigen dieses Dashboards ist eine Browserauthentifizierung erforderlich.", + "dash.permissionDenied": "Dieser Browser darf das Dashboard nicht lesen. Prüfen Sie die Zugriffsrechte mit dem Serverbetreiber.", + "dash.dataUnavailable": "Dashboard-Daten konnten nicht geladen werden. Prüfen Sie die Verbindung und versuchen Sie es erneut.", + "dash.staleData": "Die zuletzt empfangenen Daten werden angezeigt; sie können veraltet sein.", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 74e4ded4d1..c9144ca6e6 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2649,6 +2649,17 @@ export const en = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "Hub", + "connection.pairing.getCode": "Run this command on the hub for this browser:", + "connection.pairing.askOperator": "If someone else operates the hub, send them this command and ask for a one-time pairing code.", + "connection.pairing.notApiKey": "Paste a one-time pairing code here. Data API keys and admin tokens do not belong in this field.", + "connection.pairing.networkError": "Could not reach the hub. Check the connection and retry; your code is still here.", + "connection.pairing.requestError": "The hub could not complete the pairing request. Check its status and retry.", + "connection.pairing.responseError": "The hub did not return a valid browser session. Update the hub or ask its operator, then retry.", + "dash.authRequired": "Browser authentication is required to view this dashboard.", + "dash.permissionDenied": "This browser is not permitted to read the dashboard. Check access settings with the server operator.", + "dash.dataUnavailable": "Dashboard data could not be loaded. Check the connection and retry.", + "dash.staleData": "Showing the last received data; it may be out of date.", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2bc3d5ab51..59b4802348 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2562,6 +2562,17 @@ export const fr: Record = { "connection.machineUnavailable": "Le plan machine local est indisponible. Les requêtes partagées n'ont pas été redirigées localement.", "connection.disconnect": "Déconnecter du hub", "connection.disconnectConfirm": "Déconnecter cette machine du hub et la redémarrer en mode autonome ?", + "connection.pairing.hub": "Hub", + "connection.pairing.getCode": "Exécutez cette commande sur le hub pour ce navigateur :", + "connection.pairing.askOperator": "Si une autre personne gère le hub, transmettez-lui cette commande et demandez un code de jumelage à usage unique.", + "connection.pairing.notApiKey": "Collez ici un code de jumelage à usage unique. Ce champ ne reçoit ni clé API de données ni jeton administrateur.", + "connection.pairing.networkError": "Impossible de joindre le hub. Vérifiez la connexion et réessayez ; votre code est conservé.", + "connection.pairing.requestError": "Le hub n’a pas pu terminer la demande de jumelage. Vérifiez son état et réessayez.", + "connection.pairing.responseError": "Le hub n’a pas renvoyé de session de navigateur valide. Mettez-le à jour ou contactez son responsable, puis réessayez.", + "dash.authRequired": "Une authentification du navigateur est nécessaire pour consulter ce tableau de bord.", + "dash.permissionDenied": "Ce navigateur n’a pas accès au tableau de bord. Vérifiez les autorisations avec le responsable du serveur.", + "dash.dataUnavailable": "Impossible de charger les données du tableau de bord. Vérifiez la connexion et réessayez.", + "dash.staleData": "Les dernières données reçues sont affichées ; elles peuvent être obsolètes.", "connection.pairing.title": "Connecter ce tableau de bord au hub", "connection.pairing.body": "Collez le code d'association à usage unique créé sur le hub.", "connection.pairing.relayWarning": "Ce code passe par le relais fixe du hub. Le relais ne peut pas viser un autre hôte.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a787ab735b..6f2be98287 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2595,6 +2595,17 @@ export const ja: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "ハブ", + "connection.pairing.getCode": "このブラウザーを認証するには、ハブで次のコマンドを実行してください。", + "connection.pairing.askOperator": "ハブの管理者が別にいる場合は、このコマンドを渡して使い捨てのペアリングコードを依頼してください。", + "connection.pairing.notApiKey": "ここには使い捨てのペアリングコードを貼り付けてください。データAPIキーや管理者トークンは入力しないでください。", + "connection.pairing.networkError": "ハブに接続できません。接続を確認して再試行してください。入力したコードは保持されています。", + "connection.pairing.requestError": "ハブがペアリング要求を完了できませんでした。状態を確認して再試行してください。", + "connection.pairing.responseError": "ハブが有効なブラウザーセッションを返しませんでした。ハブを更新するか管理者に確認して再試行してください。", + "dash.authRequired": "このダッシュボードを表示するにはブラウザーの認証が必要です。", + "dash.permissionDenied": "このブラウザーにはダッシュボードの閲覧権限がありません。サーバー管理者にアクセス設定を確認してください。", + "dash.dataUnavailable": "ダッシュボードのデータを読み込めませんでした。接続を確認して再試行してください。", + "dash.staleData": "最後に受信したデータを表示しています。最新の状態とは異なる場合があります。", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ccd2b06735..af866c00ea 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2596,6 +2596,17 @@ export const ko: Record = { "connection.machineUnavailable": "로컬 머신 연결을 사용할 수 없습니다. 공유 요청을 로컬로 우회하지 않았습니다.", "connection.disconnect": "허브 연결 해제", "connection.disconnectConfirm": "이 머신의 허브 연결을 해제하고 독립 실행 모드로 다시 시작할까요?", + "connection.pairing.hub": "허브", + "connection.pairing.getCode": "이 브라우저를 인증하려면 허브에서 다음 명령을 실행하세요.", + "connection.pairing.askOperator": "허브를 다른 사람이 운영한다면 이 명령을 전달하고 일회용 페어링 코드를 요청하세요.", + "connection.pairing.notApiKey": "이 칸에는 일회용 페어링 코드를 붙여 넣으세요. 데이터 API 키나 관리자 토큰을 입력하는 칸이 아닙니다.", + "connection.pairing.networkError": "허브에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요. 입력한 코드는 유지됩니다.", + "connection.pairing.requestError": "허브가 페어링 요청을 완료하지 못했습니다. 허브 상태를 확인하고 다시 시도하세요.", + "connection.pairing.responseError": "허브가 유효한 브라우저 세션을 반환하지 않았습니다. 허브를 업데이트하거나 운영자에게 확인한 뒤 다시 시도하세요.", + "dash.authRequired": "이 대시보드를 보려면 브라우저 인증이 필요합니다.", + "dash.permissionDenied": "이 브라우저에는 대시보드 조회 권한이 없습니다. 서버 운영자에게 접근 설정을 확인하세요.", + "dash.dataUnavailable": "대시보드 데이터를 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요.", + "dash.staleData": "마지막으로 받은 데이터를 표시합니다. 최신 상태와 다를 수 있습니다.", "connection.pairing.title": "이 대시보드를 허브에 연결", "connection.pairing.body": "허브에서 만든 일회용 페어링 코드를 붙여 넣으세요.", "connection.pairing.relayWarning": "이 코드는 고정 허브 릴레이로 교환됩니다. 릴레이 목적지는 다른 호스트로 바꿀 수 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index ceb0c18a92..ed1c97f938 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2597,6 +2597,17 @@ export const ru: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "Хаб", + "connection.pairing.getCode": "Выполните эту команду на хабе для этого браузера:", + "connection.pairing.askOperator": "Если хабом управляет другой человек, передайте ему эту команду и попросите одноразовый код сопряжения.", + "connection.pairing.notApiKey": "Вставьте сюда одноразовый код сопряжения. Ключи API данных и токены администратора в это поле не вводятся.", + "connection.pairing.networkError": "Не удалось связаться с хабом. Проверьте соединение и повторите попытку; введённый код сохранён.", + "connection.pairing.requestError": "Хаб не смог завершить запрос сопряжения. Проверьте его состояние и повторите попытку.", + "connection.pairing.responseError": "Хаб не вернул действительный сеанс браузера. Обновите хаб или обратитесь к его оператору и повторите попытку.", + "dash.authRequired": "Для просмотра этой панели требуется аутентификация браузера.", + "dash.permissionDenied": "У этого браузера нет доступа к панели. Уточните настройки доступа у оператора сервера.", + "dash.dataUnavailable": "Не удалось загрузить данные панели. Проверьте соединение и повторите попытку.", + "dash.staleData": "Показаны последние полученные данные; они могут быть устаревшими.", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 26a8f93d09..cf9797a91f 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2597,6 +2597,17 @@ export const tr: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "Merkez", + "connection.pairing.getCode": "Bu tarayıcı için merkezde şu komutu çalıştırın:", + "connection.pairing.askOperator": "Merkezi başka biri yönetiyorsa bu komutu ona gönderip tek kullanımlık eşleştirme kodu isteyin.", + "connection.pairing.notApiKey": "Buraya tek kullanımlık eşleştirme kodunu yapıştırın. Veri API anahtarları ve yönetici belirteçleri bu alana girilmez.", + "connection.pairing.networkError": "Merkeze ulaşılamadı. Bağlantıyı kontrol edip yeniden deneyin; kodunuz korunuyor.", + "connection.pairing.requestError": "Merkez eşleştirme isteğini tamamlayamadı. Durumunu kontrol edip yeniden deneyin.", + "connection.pairing.responseError": "Merkez geçerli bir tarayıcı oturumu döndürmedi. Merkezi güncelleyin veya yöneticisine danışıp yeniden deneyin.", + "dash.authRequired": "Bu panoyu görüntülemek için tarayıcı kimlik doğrulaması gerekiyor.", + "dash.permissionDenied": "Bu tarayıcının panoyu okuma izni yok. Erişim ayarlarını sunucu yöneticisiyle kontrol edin.", + "dash.dataUnavailable": "Pano verileri yüklenemedi. Bağlantıyı kontrol edip yeniden deneyin.", + "dash.staleData": "Son alınan veriler gösteriliyor; güncel olmayabilir.", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 1fb8387f4d..6b031d669f 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2560,6 +2560,17 @@ export const zhTW: Record = { "connection.machineUnavailable": "本機機器平面無法使用。共享請求未改用本機資料。", "connection.disconnect": "中斷 Hub 連線", "connection.disconnectConfirm": "要中斷此機器與 Hub 的連線,並以獨立模式重新啟動嗎?", + "connection.pairing.hub": "中樞", + "connection.pairing.getCode": "請在中樞上為此瀏覽器執行以下命令:", + "connection.pairing.askOperator": "如果中樞由他人管理,請將此命令交給管理員並索取一次性配對碼。", + "connection.pairing.notApiKey": "請在此貼上一次性配對碼。此欄位不接受資料 API 金鑰或管理員權杖。", + "connection.pairing.networkError": "無法連線至中樞。請檢查連線後重試;已輸入的配對碼會保留。", + "connection.pairing.requestError": "中樞無法完成配對請求。請檢查其狀態後重試。", + "connection.pairing.responseError": "中樞未傳回有效的瀏覽器工作階段。請更新中樞或聯絡管理員後重試。", + "dash.authRequired": "檢視此儀表板需要瀏覽器身分驗證。", + "dash.permissionDenied": "此瀏覽器無權讀取儀表板。請聯絡伺服器管理員檢查存取設定。", + "dash.dataUnavailable": "無法載入儀表板資料。請檢查連線後重試。", + "dash.staleData": "正在顯示最後收到的資料,可能已過時。", "connection.pairing.title": "將此儀表板連接到 Hub", "connection.pairing.body": "貼上在 Hub 建立的一次性配對碼。", "connection.pairing.relayWarning": "此代碼透過固定 Hub 轉送交換,無法重新導向其他主機。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 4e2cd831cc..2735475749 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2595,6 +2595,17 @@ export const zh: Record = { "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", "connection.disconnect": "Disconnect from hub", "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.hub": "中心", + "connection.pairing.getCode": "请在中心上为此浏览器运行以下命令:", + "connection.pairing.askOperator": "如果中心由他人管理,请将此命令发给管理员并索取一次性配对码。", + "connection.pairing.notApiKey": "请在此粘贴一次性配对码。此字段不接受数据 API 密钥或管理员令牌。", + "connection.pairing.networkError": "无法连接中心。请检查连接后重试;已输入的配对码会保留。", + "connection.pairing.requestError": "中心无法完成配对请求。请检查其状态后重试。", + "connection.pairing.responseError": "中心未返回有效的浏览器会话。请更新中心或联系管理员后重试。", + "dash.authRequired": "查看此仪表板需要浏览器身份验证。", + "dash.permissionDenied": "此浏览器无权读取仪表板。请联系服务器管理员检查访问设置。", + "dash.dataUnavailable": "无法加载仪表板数据。请检查连接后重试。", + "dash.staleData": "正在显示最后收到的数据,可能已过时。", "connection.pairing.title": "Connect this dashboard to the hub", "connection.pairing.body": "Paste the one-time pairing code created on the hub.", "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index e32a671ec8..16b221834f 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ import { type ReactNode } from "react"; import { IconAlert } from "../icons"; import { Trans } from "../i18n/provider"; import { navigateHash } from "../hash-routing"; -import { EmptyState } from "../ui"; +import { EmptyState, Notice } from "../ui"; import { DashboardDialogs } from "./dashboard-dialogs"; import { DashboardModelsSection } from "./dashboard-models-section"; import { DashboardOverviewSection } from "./dashboard-overview-section"; @@ -18,19 +18,25 @@ function selectDashboardTab(next: DashboardSection) { navigateHash(dashboardHashForSection(next)); } -export default function Dashboard({ apiBase }: { apiBase: string }) { - const d = useDashboardData(apiBase); +export default function Dashboard({ apiBase, connected = false, authenticationPending = false, refreshEpoch = 0 }: { + apiBase: string; connected?: boolean; authenticationPending?: boolean; refreshEpoch?: number; +}) { + const d = useDashboardData(apiBase, refreshEpoch); const { t, error, selectedSection, providers, models, modelsLoading, modelQuery, setModelQuery, filteredGroups, expandedProviders, setExpandedProviders, } = d; - if (error) { + if (authenticationPending) return null; + const accessFailure = d.connectionFailure === "auth" || d.connectionFailure === "denied"; + if (error && (accessFailure || !d.health)) { return ( } - title={{t("dash.cannotConnect")}}> - + title={{t(d.connectionFailure === "denied" + ? "dash.permissionDenied" : d.connectionFailure === "auth" ? "dash.authRequired" : "dash.dataUnavailable")}}> + {!connected && d.connectionFailure === "unavailable" && } + ); } @@ -74,6 +80,8 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { return (
+ {error && {t("dash.staleData")} }

{t("nav.dashboard")}

diff --git a/gui/src/pages/dashboard-core-poll.ts b/gui/src/pages/dashboard-core-poll.ts index f6bb653452..1b1e98461e 100644 --- a/gui/src/pages/dashboard-core-poll.ts +++ b/gui/src/pages/dashboard-core-poll.ts @@ -51,6 +51,7 @@ export type DashboardOverviewPoll = { health: HealthData | null; providers: ProviderInfo[]; error: boolean; + failure?: "auth" | "denied" | "request" | "invalid" | "unavailable"; }; /** Multi-agent extras — slower peers must not gate status/uptime/provider counts. */ @@ -252,16 +253,46 @@ export async function fetchDashboardOverview( apiBase: string, signal: AbortSignal, ): Promise { + const failed = (failure: DashboardOverviewPoll["failure"]): DashboardOverviewPoll => ({ health: null, providers: [], error: true, failure }); + const controller = new AbortController(); + const requestSignal = AbortSignal.any([signal, controller.signal]); + let accessFailure: "auth" | "denied" | undefined; + let notifyAccess!: () => void; + const accessChanged = new Promise<"access">(resolve => { notifyAccess = () => resolve("access"); }); + const observe = (response: Response) => { + if (response.status === 401 || response.status === 403) { + if (response.status === 403 || accessFailure === undefined) accessFailure = response.status === 403 ? "denied" : "auth"; + notifyAccess(); + } + return response; + }; try { - const [hRes, pRes] = await Promise.all([ - fetch(`${apiBase}/api/system/health`, { signal }), - fetch(`${apiBase}/api/providers`, { signal }), - ]); - const health = await requireJson(hRes); - const providers = await requireJson(pRes); - return { health, providers, error: false }; - } catch { - return { health: null, providers: [], error: true }; + const requests = ["/api/system/health", "/api/providers"].map(path => + Promise.resolve().then(() => fetch(`${apiBase}${path}`, { signal: requestSignal })).then(observe)); + // Access failure must not be lost behind a rejected or stalled peer request. + const result = await Promise.race([Promise.allSettled(requests), accessChanged]); + signal.throwIfAborted(); + if (result === "access") return failed(accessFailure ?? "auth"); + const [hResult, pResult] = result; + if (hResult.status === "rejected" || pResult.status === "rejected") return failed("unavailable"); + const hRes = hResult.value; + const pRes = pResult.value; + if (!hRes.ok || !pRes.ok) return failed("request"); + try { + const health = await requireJson(hRes); + const providers = await requireJson(pRes); + if (!health || typeof health.status !== "string" || typeof health.version !== "string" + || !Number.isFinite(health.uptime) || health.uptime < 0 + || !Array.isArray(providers) || providers.some(row => !row || typeof row.name !== "string" + || typeof row.adapter !== "string" || typeof row.baseUrl !== "string" || typeof row.hasApiKey !== "boolean" + || (row.defaultModel !== undefined && typeof row.defaultModel !== "string"))) return failed("invalid"); + return { health, providers, error: false }; + } catch (error) { + if (isAbortError(error, signal)) throw error; + return failed("invalid"); + } + } finally { + controller.abort(); } } diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 5bbe1210bc..fdff9caf08 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -1,3 +1,4 @@ +import { classifyDataSurface } from "../data-surface"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useKeyedClientResource } from "../client-resource"; import { replaceHash } from "../hash-routing"; @@ -135,7 +136,7 @@ function controlsCacheKey(apiBase: string): string { return `${CONTROLS_CACHE_PREFIX}${apiBase}`; } -export function useDashboardData(apiBase: string) { +export function useDashboardData(apiBase: string, refreshEpoch = 0) { const { locale, t } = useI18n(); // The hash is the source of truth for the active section (#dashboard, …). const [selectedSection, setSelectedSection] = useState(readDashboardSectionFromHash); @@ -253,7 +254,7 @@ export function useDashboardData(apiBase: string) { const startupHealthPoll = useKeyedClientResource( `dashboard-startup-health:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchStartupHealth(apiBase, signal), { pollMs: 30_000 }, ); @@ -275,23 +276,24 @@ export function useDashboardData(apiBase: string) { // Wave 1: status/uptime/providers must not wait on injection-model / usage. const overviewPoll = useKeyedClientResource( `dashboard-overview:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchDashboardOverview(apiBase, signal), { pollMs: 5000 }, ); + const overviewSurface = classifyDataSurface(overviewPoll, data => data.health === null, true); const overviewReady = health !== null || overviewPoll.data !== undefined; // Preferences that are just config — never gate on overview or injection. const maModePoll = useKeyedClientResource( `dashboard-ma-mode:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchDashboardMaMode(apiBase, signal), { pollMs: 5000 }, ); const sidecarPoll = useKeyedClientResource( `dashboard-sidecars:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], async (signal) => { const startupHealthGeneration = startupHealthGenerationRef.current; const data = await fetchDashboardSidecars(apiBase, signal, epochRefs); @@ -302,7 +304,7 @@ export function useDashboardData(apiBase: string) { const settingsPoll = useKeyedClientResource( `dashboard-settings:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], async (signal) => { const startupHealthGeneration = startupHealthGenerationRef.current; const data = await fetchDashboardSettings(apiBase, signal, epochRefs); @@ -314,14 +316,14 @@ export function useDashboardData(apiBase: string) { // Wave 2: heavier peers start after overview commits (or session seed) to cut contention. const multiAgentPoll = useKeyedClientResource( `dashboard-multi-agent:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchDashboardMultiAgent(apiBase, signal), { pollMs: 5000, enabled: overviewReady }, ); const usagePoll = useKeyedClientResource( usageSummary30dResourceKey(apiBase), - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchDashboardUsage(apiBase, signal), // 30d usage is documented ~5s cold; this shared key has four subscribers, so // every one of them carries the same raised deadline (mount-order independent). @@ -330,14 +332,14 @@ export function useDashboardData(apiBase: string) { const diagnosticsPoll = useKeyedClientResource( `dashboard-diagnostics:${apiBase}`, - [apiBase], + [apiBase, refreshEpoch], (signal) => fetchProjectConfigDiagnostics(apiBase, signal), { pollMs: PROJECT_CONFIG_DIAGNOSTICS_POLL_MS, enabled: overviewReady }, ); const modelsPoll = useKeyedClientResource( `dashboard-models:${apiBase}`, - [apiBase, error], + [apiBase, error, refreshEpoch], (signal) => fetchDashboardModels(apiBase, signal), { enabled: overviewReady && !error }, ); @@ -850,7 +852,8 @@ export function useDashboardData(apiBase: string) { effortCap, subagentEffortCap, effortCapSaving, setEffortCap, setSubagentEffortCap, setEffortCapSaving, syncResult, syncError, projectConfigWarnings, updateOpen, updateChannel, setUpdateRestart, updateRestart, updateLoading, - updateCheck, updateError, updateJob, reconnecting, error, + updateCheck, updateError, updateJob, reconnecting, error: error || overviewSurface.showError, + connectionFailure: overviewPoll.data?.failure ?? (overviewSurface.showError ? "unavailable" : undefined), refreshDashboard: overviewPoll.refresh, effortCapHelpTriggerRef, updateTriggerRef, maHelpTriggerRef, shadowCallHelpTriggerRef, effortCapHelpDialogRef, updateDialogRef, maHelpDialogRef, shadowCallHelpDialogRef, filteredGroups, sidecarModels, visionModels, diff --git a/gui/tests/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts index ae68a1d1f7..05fcaa4239 100644 --- a/gui/tests/connect-pairing.test.ts +++ b/gui/tests/connect-pairing.test.ts @@ -5,7 +5,9 @@ import { act, createElement } from "react"; test("App mounts the relay pairing form and installs only the returned shared session", async () => { const keys = ["window", "document", "navigator", "sessionStorage", "localStorage", "fetch", "confirm", "alert", "IS_REACT_ACT_ENVIRONMENT", "__APP_VERSION__"] as const; const previous = Object.fromEntries(keys.map(key => [key, Reflect.get(globalThis, key)])); - const win = new Window({ url: "http://localhost/#usage" }); + const win = new Window({ url: "http://localhost/#dashboard" }); + // Hidden documents have no periodic resource poll: pairing must explicitly revalidate. + Object.defineProperty(win.document, "visibilityState", { configurable: true, value: "hidden" }); Object.defineProperties(globalThis, { window: { configurable: true, value: win }, document: { configurable: true, value: win.document }, @@ -33,6 +35,9 @@ test("App mounts the relay pairing form and installs only the returned shared se document.head.append(meta); } + let authorized = false; + let rejectSession = false; + let authenticatedHealthReads = 0; let pairingRequest: { method: string; body: string; headers: Headers } | null = null; const sessionHtml = [ '', @@ -51,9 +56,21 @@ test("App mounts the relay pairing form and installs only the returned shared se hubReachability: "unknown", }); if (url.pathname === "/api/machine/hub-relay/opencodex-session" && init?.method === "POST") { + authorized = true; rejectSession = false; pairingRequest = { method: init.method, body: String(init.body), headers }; return new Response(sessionHtml, { headers: { "Content-Type": "text/html" } }); } + if (url.pathname.endsWith("/opencodex-session")) return new Response(null, { status: 401 }); + if (url.pathname.endsWith("/api/system/health")) { + if (!authorized || rejectSession) return new Response(null, { status: 401 }); + expect(headers.get("x-opencodex-api-key")).toBe("ocx_session_hub"); + authenticatedHealthReads++; + return Response.json({ status: "ok", version: "0.0.0-test", uptime: 30 }); + } + if (url.pathname.endsWith("/api/providers")) return Response.json([ + { name: "fixture", adapter: "openai-chat", baseUrl: "https://fixture.example.test", hasApiKey: false }, + ]); + if (url.pathname.endsWith("/api/models")) return Response.json([]); if (url.pathname === "/healthz") return Response.json({ version: "0.0.0-test" }); if (url.pathname.endsWith("/api/usage")) return Response.json({ range: "30d", surface: "all", since: null, generatedAt: Date.now(), @@ -85,6 +102,11 @@ test("App mounts the relay pairing form and installs only the returned shared se installApiAuthFetch(); const { default: App } = await import("../src/App"); Object.defineProperty(globalThis, "fetch", { configurable: true, value: win.fetch }); + const resources = await import("../src/client-resource"); + resources.clearClientResourceStoresForTests(); + resources.setClientResourceData("dashboard-overview:http://localhost/api/machine/hub-relay", { + health: null, providers: [], error: true, failure: "auth", + }); const { createRoot } = await import("react-dom/client"); const root = createRoot(container); try { @@ -94,6 +116,10 @@ test("App mounts the relay pairing form and installs only the returned shared se if (Date.now() >= deadline) throw new Error("pairing form did not mount from App"); await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 10)); }); } + expect(container.textContent).toContain("https://hub.example.test"); + expect(container.textContent).toContain('ocx gui pair --origin "http://localhost"'); + expect(container.textContent).not.toContain("ocx start"); + expect(container.querySelector(".dashboard-workspace-shell")).toBeNull(); const input = container.querySelector("#connect-pairing-code") as HTMLInputElement; Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, `ocx_pair_${"a".repeat(43)}`); await act(async () => { input.dispatchEvent(new win.Event("input", { bubbles: true })); }); @@ -108,8 +134,36 @@ test("App mounts the relay pairing form and installs only the returned shared se expect(pairingRequest?.body).toBe(JSON.stringify({ grant: `ocx_pair_${"a".repeat(43)}` })); expect(pairingRequest?.headers.get("x-opencodex-machine-session")).toBe("ocx_session_machine"); expect(pairingRequest?.headers.get("x-opencodex-api-key")).toBeNull(); + const refreshDeadline = Date.now() + 5_000; + while (authenticatedHealthReads === 0 || !container.querySelector(".dashboard-workspace-shell")) { + if (Date.now() >= refreshDeadline) throw new Error("pairing did not refresh the retained failed dashboard store"); + await act(async () => { await new Promise(resolve => setImmediate(resolve)); }); + } + await act(async () => { + resources.setClientResourceData("dashboard-overview:http://localhost/api/machine/hub-relay", { + health: null, providers: [], error: true, failure: "unavailable", + }); + }); + expect(container.querySelector(".dashboard-workspace-shell")).not.toBeNull(); + expect(container.textContent).toContain("Showing the last received data"); + expect(container.textContent).not.toContain("ocx start"); + await act(async () => { + resources.setClientResourceData("dashboard-overview:http://localhost/api/machine/hub-relay", { + health: null, providers: [], error: true, failure: "denied", + }); + }); + expect(container.querySelector(".dashboard-workspace-shell")).toBeNull(); + expect(container.textContent).toContain("not permitted to read the dashboard"); + expect(container.textContent).not.toContain("ocx start"); + rejectSession = true; + await act(async () => { expect((await fetch("http://localhost/api/machine/hub-relay/api/system/health")).status).toBe(401); }); + expect(container.querySelector("#connect-pairing-code")).not.toBeNull(); + expect(container.querySelector(".dashboard-workspace-shell")).toBeNull(); + expect(container.textContent).not.toContain("ocx start"); + } finally { await act(async () => { root.unmount(); }); + resources.clearClientResourceStoresForTests(); container.remove(); win.close(); for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: previous[key] }); @@ -161,3 +215,33 @@ test("a refused pairing renders an accessible error without clearing the pasted for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: previous[key] }); } }); + + +test("a cancelled pairing body cannot install its obsolete session", async () => { + const { submitConnectPairing } = await import("../src/connect-pairing-transport"); + const controller = new AbortController(); + let release!: (text: string) => void; + let reading!: () => void; + const started = new Promise(resolve => { reading = resolve; }); + const response = new Response(""); + response.text = () => new Promise(resolve => { release = resolve; reading(); }); + const pending = submitConnectPairing({ id: "shared", baseUrl: "https://hub.example.test", + serverOrigin: "https://hub.example.test", bootstrapPath: "https://hub.example.test/opencodex-session", transport: "direct" }, + `ocx_pair_${"a".repeat(43)}`, (async () => response) as typeof fetch, controller.signal); + await started; + controller.abort(); + release(''); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); +}); + +test("pairing reports refusal, server failure and network failure separately", async () => { + const { submitConnectPairing } = await import("../src/connect-pairing-transport"); + const target = { id: "shared" as const, baseUrl: "https://hub.example.test", serverOrigin: "https://hub.example.test", + bootstrapPath: "https://hub.example.test/opencodex-session", transport: "direct" as const }; + for (const [status, kind] of [[403, "refused"], [503, "request-failed"]] as const) { + await expect(submitConnectPairing(target, `ocx_pair_${"a".repeat(43)}`, + (async () => new Response(null, { status })) as typeof fetch)).rejects.toMatchObject({ kind }); + } + await expect(submitConnectPairing(target, `ocx_pair_${"a".repeat(43)}`, + (async () => { throw new Error("network"); }) as typeof fetch)).rejects.toMatchObject({ kind: "unreachable" }); +}); diff --git a/gui/tests/dashboard-connection-state.test.ts b/gui/tests/dashboard-connection-state.test.ts new file mode 100644 index 0000000000..9628ce1628 --- /dev/null +++ b/gui/tests/dashboard-connection-state.test.ts @@ -0,0 +1,50 @@ +import { afterEach, expect, test } from "bun:test"; +import { fetchDashboardOverview } from "../src/pages/dashboard-core-poll"; + +const originalFetch = globalThis.fetch; +const health = { status: "ok", version: "1.0.0", uptime: 10 }; +const providers = [{ name: "fixture", adapter: "openai-chat", baseUrl: "https://fixture.example.test", hasApiKey: false }]; +afterEach(() => { globalThis.fetch = originalFetch; }); + +test.each([[401, "auth"], [403, "denied"], [500, "request"]] as const)("HTTP %i has its own dashboard failure meaning", async (status, failure) => { + globalThis.fetch = (async () => new Response(null, { status })) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ error: true, failure }); +}); + +test.each([{}, { ...health, uptime: "ten" }, { ...health, uptime: -1 }])("malformed health JSON is not treated as a stopped proxy", async invalid => { + globalThis.fetch = (async input => Response.json(String(input).endsWith("/api/providers") ? providers : invalid)) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "invalid" }); +}); + +test.each([{}, [null], [{ name: "fixture" }]])("malformed providers are rejected before caching", async invalid => { + globalThis.fetch = (async input => Response.json(String(input).endsWith("/api/providers") ? invalid : health)) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "invalid" }); +}); + +test("valid dashboard response retains the existing success shape", async () => { + globalThis.fetch = (async input => Response.json(String(input).endsWith("/api/providers") ? providers : health)) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toEqual({ health, providers, error: false }); +}); + +test("transport failure is distinct from cancelled polling", async () => { + globalThis.fetch = (async () => { throw new TypeError("network unavailable"); }) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "unavailable" }); + const controller = new AbortController(); controller.abort(); + await expect(fetchDashboardOverview("", controller.signal)).rejects.toThrow(); +}); + +for (const deniedPath of ["/api/system/health", "/api/providers"]) { + test(`a 403 from ${deniedPath} outranks a rejected peer`, async () => { + globalThis.fetch = (async input => { + if (String(input).endsWith(deniedPath)) return new Response(null, { status: 403 }); + throw new TypeError("network failed"); + }) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "denied" }); + }); + test(`a 403 from ${deniedPath} does not wait for a stalled peer`, async () => { + globalThis.fetch = ((input, init) => String(input).endsWith(deniedPath) + ? Promise.resolve(new Response(null, { status: 403 })) + : new Promise((_resolve, reject) => { init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }); })) as typeof fetch; + expect(await fetchDashboardOverview("", new AbortController().signal)).toMatchObject({ failure: "denied" }); + }); +} diff --git a/gui/tests/dashboard-resource-deadline.test.tsx b/gui/tests/dashboard-resource-deadline.test.tsx new file mode 100644 index 0000000000..be66d644c4 --- /dev/null +++ b/gui/tests/dashboard-resource-deadline.test.tsx @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { LanguageProvider } from "../src/i18n/provider"; +import { useDashboardData } from "../src/pages/use-dashboard-data"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +test("a controlled overview deadline marks retained dashboard data stale", async () => { + const keys = ["window", "document", "navigator", "sessionStorage", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; + const previous = new Map(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.document, "visibilityState", { configurable: true, value: "hidden" }); + for (const [key, value] of Object.entries({ window: win, document: win.document, navigator: win.navigator, + sessionStorage: win.sessionStorage, localStorage: win.localStorage, IS_REACT_ACT_ENVIRONMENT: true })) { + Object.defineProperty(globalThis, key, { configurable: true, value }); + } + const originalTimeout = globalThis.setTimeout; + let deadline: (() => void) | undefined; + let stall = false; + let started = false; + Object.defineProperty(globalThis, "setTimeout", { configurable: true, value: (callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]) => { + if (stall && ms === 30_000 && !deadline) deadline = () => callback(...args); + return originalTimeout(callback, ms, ...args); + } }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.endsWith("/api/system/health")) { + if (stall) { + started = true; + return new Promise((_resolve, reject) => init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true })); + } + return Response.json({ status: "ok", version: "fixture", uptime: 10 }); + } + if (path.endsWith("/api/providers") || path.endsWith("/api/models")) return Response.json([]); + return new Response(null, { status: 404 }); + } }); + clearClientResourceStoresForTests(); + let data: ReturnType | undefined; + function Probe() { + data = useDashboardData("/deadline"); + return {data.error ? "stale" : "fresh"}:{data.health?.version}; + } + const { createRoot } = await import("react-dom/client"); + const host = win.document.createElement("div"); + win.document.body.append(host); + const root = createRoot(host); + const waitFor = async (predicate: () => boolean) => { + const until = Date.now() + 5000; + while (!predicate()) { + if (Date.now() >= until) throw new Error("dashboard fixture did not settle"); + await act(async () => { await new Promise(resolve => setImmediate(resolve)); }); + } + }; + try { + await act(async () => { root.render(); }); + await waitFor(() => data?.health?.version === "fixture"); + expect(host.textContent).toBe("fresh:fixture"); + stall = true; + await act(async () => { data!.refreshDashboard(); }); + await waitFor(() => started && deadline !== undefined); + await act(async () => { deadline!(); }); + await waitFor(() => data?.error === true); + expect(host.textContent).toBe("stale:fixture"); + expect(data!.connectionFailure).toBe("unavailable"); + } finally { + await act(async () => { root.unmount(); }); + clearClientResourceStoresForTests(); + Object.defineProperty(globalThis, "setTimeout", { configurable: true, writable: true, value: originalTimeout }); + win.close(); + for (const key of keys) { + const descriptor = previous.get(key); + if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); + } + } +}); diff --git a/structure/design-methodology.md b/structure/design-methodology.md index 690b3b1287..9af35d91fc 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -40,4 +40,6 @@ surfaces, run through all 3 stages in order. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). +The pairing panel names the hub, offers an origin-specific command to run on that hub, and separates one-time codes from data/admin credentials. Copy outcomes and request failures use existing notice/button patterns. Failed authentication never masquerades as a stopped connected process. + Cline uses the existing file-integration page, tabs, status badge and rollback dialogs. Its localized semantics identify both files and the required stop/restart boundary before users mutate them. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 73090d646e..84296ccfc6 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -538,8 +538,12 @@ advances the observation clock, so a retained older row cannot defer evaluation Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. +The connected browser shell reuses `SESSION_UNAVAILABLE_EVENT` and its shared-session readiness state. Terminal 401 recovery failure exposes pairing without a restart instruction; a newer session or aborted request cannot publish an unavailable notice. Successful pairing changes dashboard resource revalidation dependencies, so retained failed stores are explicitly refreshed. Dashboard reads distinguish authentication, permission denial, request failure, invalid payload and transport failure; protected data is hidden for authentication/denial, while other failed refreshes label retained data as stale. + Cline journal Undo eligibility reads both native configuration files through the paired integration IO adapter. Its snapshot fingerprint cannot be checked against providers.json alone; [the integration contract](clients/integrations.md#cline-paired-files) defines recovery. The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files. + +Dashboard overview polling observes authorization failures independently of stalled or rejected peer requests, cancels remaining child requests after a decisive result, and exposes resource-level deadline failures without rewriting them as authentication failures. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 6655aad7db..57803716f3 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -313,6 +313,8 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +Hub/browser pairing instructions distinguish machine enrollment, session authentication, permission denial and network failure. The hosted dashboard preview is the render artifact used to review these states. + The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. diff --git a/structure/overview.md b/structure/overview.md index d5d1a2207a..b3fe226b9b 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -107,4 +107,6 @@ would pass while the rule was violated. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). +Connected-browser pairing and dashboard failure meanings follow the [management UI contract](gui-and-management-api.md#dashboard-surfaces); machine enrollment alone does not authenticate a browser. + Cline CLI is a managed file integration: its provider settings and catalog share one recoverable journal operation. The [paired-file contract](clients/integrations.md#cline-paired-files) defines its stop/restart requirement.