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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
105 changes: 81 additions & 24 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {
Expand All @@ -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";

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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"));
Expand Down Expand Up @@ -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",
Comment on lines +223 to +225

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh targets after a successful disconnect

When connected disconnect returns its expected 202, handleStop handles only the rejection branch: targets.connected remains true and stopping remains 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 👍 / 👎.

});
// 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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate shared pages until pairing completes

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 /api/* route, whose 401 enters resolveTokenAfter401 and opens the raw admin-token modal, so the user must dismiss an unrelated credential prompt before the pairing-code form is usable; render shared pages only after sharedSessionReady, while leaving any explicitly machine-only controls available.

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>
Expand Down
164 changes: 164 additions & 0 deletions gui/src/api-targets.ts
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);
}
Loading
Loading