-
Notifications
You must be signed in to change notification settings - Fork 980
feat(two-plane): phase 4 — machine listener, gui two-plane targets, usage slice #2781
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1d99dd5
872b945
90c7e8f
ccf319c
4f27ee8
e3f6cf8
a3fe759
61710ef
fedbe0a
16ddd3e
95639f0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,15 +15,15 @@ import { SidebarGithubRow } from "./components/sidebar-github-row"; | |
| import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, 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 { installApiAuthFetch } from "./api"; | ||
| import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml } from "./api"; | ||
| import { apiBaseForPlane, discoverApiTargets, isConnectedRuntime, standaloneApiTargets, type ApiTargets } from "./api-targets"; | ||
| import { ConnectPairingForm } from "./connect-pairing"; | ||
| import { type Page } from "./app-routing"; | ||
| import { readModelsTab, type ModelsTab } from "./pages/models-tab"; | ||
| import { useAppRouteState } from "./use-app-route-state"; | ||
| import { requestProxyStop } from "./stop-proxy"; | ||
| import { useCodexRestart } from "./use-codex-restart"; | ||
|
|
||
| installApiAuthFetch(); | ||
|
|
||
| type Theme = "light" | "dark" | "system"; | ||
|
|
||
| const PAGE_TKEY: Record<Page, TKey> = { | ||
|
|
@@ -40,6 +40,9 @@ const PAGE_TKEY: Record<Page, TKey> = { | |
| }; | ||
|
|
||
| const API_BASE = import.meta.env.VITE_API_BASE || ""; | ||
| const INITIAL_TARGETS = standaloneApiTargets(API_BASE); | ||
| configureApiTargets(INITIAL_TARGETS); | ||
| installApiAuthFetch(); | ||
| const THEME_KEY = "ocx-theme"; | ||
|
|
||
| /** | ||
|
|
@@ -101,6 +104,41 @@ export default function App() { | |
| const [theme, setTheme] = useState<Theme>(readStoredTheme); | ||
| const { locale, setLocale } = useI18n(); | ||
| const t = useT(); | ||
| const [targets, setTargets] = useState<ApiTargets>(INITIAL_TARGETS); | ||
| // Standalone starts settled: there is nothing to discover, so nothing to wait for. | ||
| // Gating the page on discovery made a plain install show remote-hub loading copy before | ||
| // its own dashboard, for a feature the operator never enabled. | ||
| const [targetsSettled, setTargetsSettled] = useState(() => !isConnectedRuntime()); | ||
| const [targetError, setTargetError] = useState(false); | ||
| const [sharedSessionReady, setSharedSessionReady] = useState(() => hasApiSession("shared")); | ||
|
|
||
| useEffect(() => { | ||
| const controller = new AbortController(); | ||
| void discoverApiTargets(API_BASE, controller.signal).then(async next => { | ||
| configureApiTargets(next); | ||
| setTargets(next); | ||
| if (next.connected && !hasApiSession("shared")) { | ||
| try { | ||
| const response = await fetch(next.shared.bootstrapPath, { | ||
| cache: "no-store", | ||
| signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5_000)]), | ||
| }); | ||
| if (response.ok) installApiSessionFromHtml("shared", await response.text()); | ||
| } catch { /* pairing form remains available */ } | ||
| } | ||
| if (controller.signal.aborted) return; | ||
| setSharedSessionReady(hasApiSession("shared")); | ||
| setTargetError(false); | ||
| setTargetsSettled(true); | ||
| }).catch(() => { | ||
| if (controller.signal.aborted) return; | ||
| setTargetError(true); | ||
| setTargetsSettled(true); | ||
| }); | ||
| return () => controller.abort(); | ||
| }, []); | ||
| const machineBase = apiBaseForPlane("machine", targets); | ||
| const sharedBase = apiBaseForPlane("shared", targets); | ||
|
|
||
| // Narrow screens: the sidebar becomes an off-canvas drawer behind a hamburger toggle. | ||
| const [navOpen, setNavOpen] = useState(false); | ||
|
|
@@ -126,14 +164,14 @@ export default function App() { | |
| }, [theme]); | ||
|
|
||
| const healthPoll = useKeyedClientResource( | ||
| `app-healthz:${API_BASE}`, | ||
| [], | ||
| `app-healthz:${machineBase}`, | ||
| [machineBase, targetsSettled], | ||
| async (signal) => { | ||
| const res = await fetch(`${API_BASE}/healthz`, { signal }); | ||
| const res = await fetch(`${machineBase}/healthz`, { signal }); | ||
| if (!res.ok) return null; | ||
| return readRuntimeVersion(await res.json()); | ||
| }, | ||
| { pollMs: 30_000 }, | ||
| { pollMs: 30_000, enabled: targetsSettled }, | ||
| ); | ||
|
|
||
| const cycleTheme = () => setTheme(t => (t === "light" ? "dark" : t === "dark" ? "system" : "light")); | ||
|
|
@@ -175,15 +213,16 @@ export default function App() { | |
| // sharing a controller — the backend is already single-flight, so what is missing | ||
| // is invalidation, not mutual exclusion. | ||
| const [codexRestartEpoch, setCodexRestartEpoch] = useState(0); | ||
| const { restarting: codexRestarting, restart: handleCodexRestart } = useCodexRestart(API_BASE, { | ||
| const { restarting: codexRestarting, restart: handleCodexRestart } = useCodexRestart(sharedBase, { | ||
| onSettled: () => setCodexRestartEpoch(epoch => epoch + 1), | ||
| }); | ||
|
|
||
| const handleStop = async () => { | ||
| if (!confirm(t("dash.stopConfirm"))) return; | ||
| if (!confirm(t(targets.connected ? "connection.disconnectConfirm" : "dash.stopConfirm"))) return; | ||
| setStopping(true); | ||
| const outcome = await requestProxyStop(API_BASE, { | ||
| const outcome = await requestProxyStop(machineBase, { | ||
| formatFailure: status => t("dash.stopFailed", { status: String(status) }), | ||
| mode: targets.connected ? "client" : "standalone", | ||
| }); | ||
| // Refusals and restore failures return normally instead of dropping the connection. | ||
| // In both cases the proxy did not reach a clean-stop result, so re-enable the control | ||
|
|
@@ -214,7 +253,7 @@ export default function App() { | |
| {brand} | ||
| <div className="mobile-topbar-actions"> | ||
| <button type="button" className="sidebar-orb sidebar-orb--danger" onClick={handleStop} disabled={stopping} | ||
| aria-label={t("dash.stop")} title={t("dash.stop")}> | ||
| aria-label={t(targets.connected ? "connection.disconnect" : "dash.stop")} title={t(targets.connected ? "connection.disconnect" : "dash.stop")}> | ||
| <IconPower /> | ||
| </button> | ||
| <button type="button" className="sidebar-orb" | ||
|
|
@@ -286,8 +325,8 @@ export default function App() { | |
| <div className="sidebar-action-orbs"> | ||
| <button type="button" className="sidebar-orb sidebar-orb--danger" | ||
| onClick={handleStop} disabled={stopping} | ||
| aria-label={stopping ? t("dash.stopping") : t("dash.stop")} | ||
| title={stopping ? t("dash.stopping") : t("dash.stop")}> | ||
| aria-label={stopping ? t("dash.stopping") : t(targets.connected ? "connection.disconnect" : "dash.stop")} | ||
| title={stopping ? t("dash.stopping") : t(targets.connected ? "connection.disconnect" : "dash.stop")}> | ||
| <IconPower /> | ||
| </button> | ||
| <button type="button" className="sidebar-orb" | ||
|
|
@@ -299,7 +338,7 @@ export default function App() { | |
| </div> | ||
| </div> | ||
| <SidebarGithubRow | ||
| apiBase={API_BASE} | ||
| apiBase={sharedBase} | ||
| onOpenUpdate={() => { | ||
| // The update dialog lives on the dashboard maintenance panel. Deep-link to | ||
| // `#dashboard/update` and let the dashboard own the check/run flow — no | ||
|
|
@@ -328,16 +367,34 @@ export default function App() { | |
| detailsLabel={t("errorBoundary.details")} | ||
| reloadLabel={t("errorBoundary.reload")} | ||
| > | ||
| {page === "dashboard" && <Dashboard apiBase={API_BASE} />} | ||
| {page === "startup" && <Startup apiBase={API_BASE} />} | ||
| {page === "providers" && <Providers apiBase={API_BASE} />} | ||
| {page === "models" && <Models key={API_BASE} apiBase={API_BASE} restartEpoch={codexRestartEpoch} />} | ||
| {page === "subagents" && <Subagents key={API_BASE} apiBase={API_BASE} />} | ||
| {page === "logs" && <Logs apiBase={API_BASE} />} | ||
| {page === "usage" && <Usage apiBase={API_BASE} />} | ||
| {page === "storage" && <Storage apiBase={API_BASE} />} | ||
| {page === "codex-set" && <CodexSet apiBase={API_BASE} />} | ||
| {page === "integrations" && <Integrations apiBase={API_BASE} />} | ||
| {!targetsSettled ? ( | ||
| <div className="alert">{t("connection.discovering")}</div> | ||
| ) : ( | ||
| <> | ||
| {/* | ||
| A failed discovery is a banner, not a replacement. It used to take over the | ||
| whole body, so a slow or restarting proxy cost a standalone user their | ||
| dashboard over a plane they never turned on. The requests that actually | ||
| need the machine plane report their own errors. | ||
| */} | ||
| {targetError && ( | ||
| <div className="alert alert-err" role="alert">{t("connection.machineUnavailable")}</div> | ||
| )} | ||
| {targets.connected && !sharedSessionReady && ( | ||
| <ConnectPairingForm target={targets.shared} onConnected={() => setSharedSessionReady(true)} /> | ||
| )} | ||
| {page === "dashboard" && <Dashboard apiBase={sharedBase} />} | ||
|
Comment on lines
+383
to
+386
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On the normal connected-without-hub-session path, the pairing form and the selected shared-plane page mount together. The page immediately fetches a hub AGENTS.md reference: gui/AGENTS.md:L9-L10 Useful? React with 👍 / 👎. |
||
| {page === "startup" && <Startup apiBase={sharedBase} machineApiBase={machineBase} connected={targets.connected} />} | ||
| {page === "providers" && <Providers apiBase={sharedBase} />} | ||
| {page === "models" && <Models key={sharedBase} apiBase={sharedBase} restartEpoch={codexRestartEpoch} />} | ||
| {page === "subagents" && <Subagents key={sharedBase} apiBase={sharedBase} />} | ||
| {page === "logs" && <Logs apiBase={sharedBase} />} | ||
| {page === "usage" && <Usage apiBase={sharedBase} connected={targets.connected} apiKeyId={targets.apiKeyId} />} | ||
| {page === "storage" && <Storage apiBase={sharedBase} />} | ||
| {page === "codex-set" && <CodexSet apiBase={sharedBase} />} | ||
| {page === "integrations" && <Integrations apiBase={sharedBase} machineApiBase={machineBase} connected={targets.connected} />} | ||
| </> | ||
| )} | ||
| </ErrorBoundary> | ||
| </div> | ||
| </main> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| export type ApiPlane = "machine" | "shared"; | ||
| export type SharedTransport = "same-origin" | "direct" | "relay"; | ||
|
|
||
| /** | ||
| * The runtime role the server stated in the served document, or null when it said nothing. | ||
| * | ||
| * Read without removing the tag: unlike the session meta, which is consumed once so a | ||
| * credential does not linger in the DOM, the role is non-secret and may be read again. | ||
| */ | ||
| function runtimeRoleFromDocument(): string | null { | ||
| if (typeof document === "undefined") return null; | ||
| const meta = document.querySelector('meta[name="opencodex-runtime-role"]'); | ||
| return meta?.getAttribute("content")?.trim() || null; | ||
| } | ||
|
|
||
| /** | ||
| * Did the server say this proxy is running as a connected client? | ||
| * | ||
| * Anything else — standalone, hub, an older server that sends no tag, a separately hosted | ||
| * GUI, the Vite dev server — is treated as "not connected", which is the state that needs | ||
| * no remote-hub work and makes no remote-hub requests. | ||
| */ | ||
| export function isConnectedRuntime(): boolean { | ||
| return runtimeRoleFromDocument() === "client"; | ||
| } | ||
|
|
||
| export interface ApiTarget { | ||
| id: ApiPlane; | ||
| baseUrl: string; | ||
| serverOrigin: string; | ||
| bootstrapPath: string; | ||
| transport: SharedTransport; | ||
| } | ||
|
|
||
| export interface ApiTargets { | ||
| connected: boolean; | ||
| machine: ApiTarget; | ||
| shared: ApiTarget; | ||
| apiKeyId?: string; | ||
| } | ||
|
|
||
| export interface MachineStatusV1 { | ||
| mode: "client"; | ||
| connected: true; | ||
| machineBase: string; | ||
| sharedBase: string; | ||
| sharedServerOrigin: string; | ||
| managementTransport: "direct" | "relay"; | ||
| apiKeyId: string; | ||
| protocolVersion: 1; | ||
| connectedAt: string; | ||
| catalogSyncedAt?: string; | ||
| hubReachability: "unknown" | "online" | "offline" | "unauthorized"; | ||
| } | ||
|
|
||
| function trimBase(value: string): string { | ||
| return value.replace(/\/+$/, ""); | ||
| } | ||
|
|
||
| function absoluteBase(value: string): URL { | ||
| return new URL(value || "/", window.location.href); | ||
| } | ||
|
|
||
| function canonicalOrigin(value: string): string | null { | ||
| try { | ||
| const url = new URL(value); | ||
| if ((url.protocol !== "http:" && url.protocol !== "https:") | ||
| || url.username || url.password || url.pathname !== "/" || url.search || url.hash) return null; | ||
| return url.origin; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function target(id: ApiPlane, baseUrl: string, serverOrigin: string, transport: SharedTransport): ApiTarget { | ||
| const base = trimBase(baseUrl); | ||
| return { id, baseUrl: base, serverOrigin, bootstrapPath: `${base}/opencodex-session`, transport }; | ||
| } | ||
|
|
||
| export function standaloneApiTargets(initialBase: string): ApiTargets { | ||
| const resolved = absoluteBase(initialBase); | ||
| const baseUrl = trimBase(initialBase); | ||
| return { | ||
| connected: false, | ||
| machine: target("machine", baseUrl, resolved.origin, "same-origin"), | ||
| shared: target("shared", baseUrl, resolved.origin, "same-origin"), | ||
| }; | ||
| } | ||
|
|
||
| function validStatus(value: unknown): value is MachineStatusV1 { | ||
| if (!value || typeof value !== "object" || Array.isArray(value)) return false; | ||
| const row = value as Record<string, unknown>; | ||
| return row.mode === "client" && row.connected === true && row.protocolVersion === 1 | ||
| && (row.managementTransport === "direct" || row.managementTransport === "relay") | ||
| && typeof row.machineBase === "string" && typeof row.sharedBase === "string" | ||
| && typeof row.sharedServerOrigin === "string" && typeof row.apiKeyId === "string" | ||
| && row.apiKeyId.trim().length > 0 && typeof row.connectedAt === "string"; | ||
| } | ||
|
|
||
| export function relayUrlForPath(shared: ApiTarget, path: string): string { | ||
| if (shared.transport !== "relay" || (!path.startsWith("/api/") && path !== "/opencodex-session")) { | ||
| throw new TypeError("path is not eligible for the fixed hub relay"); | ||
| } | ||
| if (path.startsWith("//") || path.includes("\\") || /%(?:2f|5c|2e)/i.test(path) || path.includes("#")) { | ||
| throw new TypeError("encoded or authority relay path refused"); | ||
| } | ||
| return `${trimBase(shared.baseUrl)}${path}`; | ||
| } | ||
|
|
||
| export function targetsFromMachineStatus(initialBase: string, status: MachineStatusV1): ApiTargets { | ||
| if (!validStatus(status)) throw new TypeError("machine status response is invalid"); | ||
| const initial = standaloneApiTargets(initialBase); | ||
| const machineOrigin = canonicalOrigin(status.machineBase); | ||
| const sharedOrigin = canonicalOrigin(status.sharedServerOrigin); | ||
| if (!machineOrigin || machineOrigin !== initial.machine.serverOrigin || !sharedOrigin) { | ||
| throw new TypeError("machine status target origins are invalid"); | ||
| } | ||
| let advertisedShared: URL; | ||
| try { advertisedShared = new URL(status.sharedBase); } catch { throw new TypeError("machine status shared target is invalid"); } | ||
| if (advertisedShared.username || advertisedShared.password || advertisedShared.search || advertisedShared.hash) { | ||
| throw new TypeError("machine status shared target is invalid"); | ||
| } | ||
| if (status.managementTransport === "direct") { | ||
| if (advertisedShared.origin !== sharedOrigin || advertisedShared.pathname !== "/") { | ||
| throw new TypeError("machine status direct target is inconsistent"); | ||
| } | ||
| } else if (advertisedShared.origin !== machineOrigin || advertisedShared.pathname !== "/api/machine/hub-relay") { | ||
| throw new TypeError("machine status relay target is inconsistent"); | ||
| } | ||
| const machine = target("machine", trimBase(initialBase), machineOrigin, "same-origin"); | ||
| const shared = status.managementTransport === "relay" | ||
| ? target("shared", `${trimBase(initialBase)}/api/machine/hub-relay`, sharedOrigin, "relay") | ||
| : target("shared", sharedOrigin, sharedOrigin, "direct"); | ||
| return { connected: true, machine, shared, apiKeyId: status.apiKeyId }; | ||
| } | ||
|
|
||
| export function apiBaseForPlane(plane: ApiPlane, targets: ApiTargets): string { | ||
| return targets[plane].baseUrl; | ||
| } | ||
|
|
||
| export async function discoverApiTargets(initialBase: string, signal?: AbortSignal): Promise<ApiTargets> { | ||
| const standalone = standaloneApiTargets(initialBase); | ||
| // Standalone asks nothing. | ||
| // | ||
| // The server states the role in the served document, so a user who never enabled remote | ||
| // hub makes no request to a remote-hub endpoint — not even one that 404s. Discovery used | ||
| // to run unconditionally and infer standalone FROM that 404, which meant every dashboard | ||
| // load probed a feature the operator had not turned on. | ||
| // | ||
| // A missing tag means standalone too: an older server, a separately hosted GUI, or the | ||
| // Vite dev server all read as "no remote topology", which is the safe default. | ||
| if (runtimeRoleFromDocument() !== "client") return standalone; | ||
| let response: Response; | ||
| try { | ||
| response = await fetch(`${standalone.machine.baseUrl}/api/machine/status`, { signal, cache: "no-store" }); | ||
| } catch (error) { | ||
| throw new Error("local machine plane unavailable", { cause: error }); | ||
| } | ||
| if (response.status === 404) return standalone; | ||
| if (!response.ok) throw new Error(`local machine plane refused discovery (${response.status})`); | ||
| const body = await response.json().catch(() => null); | ||
| if (!validStatus(body)) throw new Error("local machine plane returned invalid status"); | ||
| return targetsFromMachineStatus(initialBase, body); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When connected disconnect returns its expected 202,
handleStophandles only the rejection branch:targets.connectedremains true andstoppingremains set while the replacement standalone server starts. The still-open SPA consequently keeps sending shared calls to the old hub/relay target and offers another machine disconnect against a server that no longer has that route until the user manually reloads; on an accepted client-mode outcome, reload the page or rediscover and reconfigure both targets after the listener returns.AGENTS.md reference: gui/AGENTS.md:L9-L10
Useful? React with 👍 / 👎.