From 8927810faf2564c00d507b9d4f9a4f88ece7a9b1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:46:08 +0200 Subject: [PATCH 1/2] refactor(gui): react-doctor cleanup for Dashboard --- gui/src/pages/Dashboard.tsx | 1419 +---------------- gui/src/pages/dashboard-core-poll.ts | 248 +++ gui/src/pages/dashboard-dialogs.tsx | 211 +++ gui/src/pages/dashboard-models-section.tsx | 82 + gui/src/pages/dashboard-overview-head.tsx | 126 ++ gui/src/pages/dashboard-overview-panels.tsx | 22 + gui/src/pages/dashboard-overview-section.tsx | 14 + gui/src/pages/dashboard-overview-sections.tsx | 350 ++++ gui/src/pages/dashboard-providers-section.tsx | 37 + gui/src/pages/dashboard-shared.ts | 189 +++ gui/src/pages/use-dashboard-data.ts | 488 ++++++ gui/tests/dashboard-contracts.test.ts | 21 +- 12 files changed, 1810 insertions(+), 1397 deletions(-) create mode 100644 gui/src/pages/dashboard-core-poll.ts create mode 100644 gui/src/pages/dashboard-dialogs.tsx create mode 100644 gui/src/pages/dashboard-models-section.tsx create mode 100644 gui/src/pages/dashboard-overview-head.tsx create mode 100644 gui/src/pages/dashboard-overview-panels.tsx create mode 100644 gui/src/pages/dashboard-overview-section.tsx create mode 100644 gui/src/pages/dashboard-overview-sections.tsx create mode 100644 gui/src/pages/dashboard-providers-section.tsx create mode 100644 gui/src/pages/dashboard-shared.ts create mode 100644 gui/src/pages/use-dashboard-data.ts diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index 4f6dd86657..074b5a4ed5 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -1,517 +1,25 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; -import { formatUptime } from "../formatUptime"; -import MemoryObservabilityCard from "../components/MemoryObservabilityCard"; -import { IconAlert, IconChevron, IconExternal, IconInfo, IconRefresh, IconSearch, IconX } from "../icons"; +import { type ReactNode } from "react"; +import { IconAlert } from "../icons"; import { Trans } from "../i18n/provider"; -import { useI18n, type TKey } from "../i18n/shared"; -import { settingsPollMayCommit, beginPollEpochs, mapStartupHealthProbe, seedStartupHealthFromSettings, PROJECT_CONFIG_DIAGNOSTICS_POLL_MS, type StartupHealthStatus } from "../startup-health-ui"; -import { formatTokens } from "../format-tokens"; -import { EmptyState, Select } from "../ui"; import { navigateHash } from "../hash-routing"; - -/** Dashboard section tabs, mirroring the Logs hash-tab contract. */ -type DashboardSection = "overview" | "providers" | "models"; - -function readDashboardSectionFromHash(): DashboardSection { - const raw = window.location.hash.replace(/^#\/?/, ""); - if (raw === "dashboard/providers") return "providers"; - if (raw === "dashboard/models") return "models"; - return "overview"; -} - -/** Overview is the bare `#dashboard`; the other sections carry a suffix. */ -function dashboardHashForSection(section: DashboardSection): string { - return section === "overview" ? "dashboard" : `dashboard/${section}`; -} - -interface HealthData { status: string; version: string; uptime: number } -// StartupHealthStatus imported from startup-health-ui. -interface ProviderInfo { name: string; adapter: string; baseUrl: string; defaultModel?: string; hasApiKey: boolean } -interface ModelInfo { id: string; provider: string; owned_by?: string } -interface SettingsData { - codexAutoStart: boolean; - port: number; - hostname: string; - startupHealth?: { - status: "native" | "protected" | "at-risk"; - routingKind: "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; - autostartEnabled: boolean; - shimCoverage: "full" | "cli-only" | "none"; - diagnosticStale: boolean; - }; -} -type SidecarBackend = "openai" | "anthropic"; -interface SidecarSetting { backend?: SidecarBackend; model: string } -interface SidecarData { webSearch: SidecarSetting; vision: SidecarSetting } -interface SidecarPatch { - webSearch?: { backend?: SidecarBackend | null; model?: string }; - vision?: { backend?: SidecarBackend | null; model?: string }; -} -interface ShadowCallData { enabled: boolean; model: string } -interface UsageSummary30d { summary: { requests: number; totalTokens: number; coverageRatio: number } } -type UpdateChannel = "latest" | "preview"; -type Installer = "npm" | "bun" | "source"; -type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed"; -interface SyncResult { - ok: boolean; - added: number; - catalogPath: string | null; - catalogExists: boolean; - cacheSynced: boolean; - message: string; - warning?: string; - staleAppServerHint?: string; - projectConfigWarnings?: ProjectCodexConfigWarning[]; -} -interface ProjectCodexConfigWarning { - path: string; - code: string; - detail: string; - message: string; -} -interface ProjectCodexConfigGroup { - path: string; - issues: string[]; - bypass: string; -} -interface UpdateCheckData { - currentVersion: string; - latestVersion: string | null; - channel: UpdateChannel; - installer: Installer; - updateAvailable: boolean; - canUpdate: boolean; - command: string; - releaseNotesUrl: string; - reason?: string; -} -interface UpdateJob { - id: string; - status: UpdateJobStatus; - currentVersion: string; - latestVersion: string | null; - channel: UpdateChannel; - installer: Installer; - restart: boolean; - command: string; - log: string[]; - error?: string; - restarted?: boolean; -} - - -const EFFORT_CAP_LEVELS = ["low", "medium", "high", "xhigh"]; -const UPDATE_CHECK_MAX_AUTO_RETRIES = 2; -const UPDATE_CHECK_RETRY_BASE_MS = 800; - -function defaultUpdateChannel(version: string | undefined): UpdateChannel { - return version?.includes("-preview.") ? "preview" : "latest"; -} - -function updateReasonLabel(reason: string | undefined, t: (key: TKey) => string): string { - switch (reason) { - case "source_checkout": return t("dash.updateReason.source_checkout"); - case "latest_unavailable": return t("dash.updateReason.latest_unavailable"); - case "already_latest": return t("dash.updateReason.already_latest"); - default: return t("dash.updateReason.unknown"); - } -} - -function updateJobLabel(status: UpdateJobStatus, t: (key: TKey) => string): string { - switch (status) { - case "running": return t("dash.updateStatus.running"); - case "restarting": return t("dash.updateStatus.restarting"); - case "succeeded": return t("dash.updateStatus.succeeded"); - case "failed": return t("dash.updateStatus.failed"); - } -} - -function mergeSidecarSetting( - current: SidecarSetting, - update?: { backend?: SidecarBackend | null; model?: string }, -): SidecarSetting { - const merged = { ...current }; - if (update?.model !== undefined) merged.model = update.model; - if (update?.backend === null) delete merged.backend; - else if (update?.backend !== undefined) merged.backend = update.backend; - return merged; -} - -function sidecarModelOptions(models: ModelInfo[]) { - return models - .filter(model => model.provider === "openai" || model.provider === "anthropic") - .map(model => ({ value: model.id, label: `${model.provider}/${model.id}` })); -} - -function sidecarBackendForModel(models: ModelInfo[], modelId: string): SidecarBackend { - return models.find(model => model.id === modelId)?.provider === "anthropic" ? "anthropic" : "openai"; -} - -/** - * Last input modality, tracked window-wide. Quiet focus restore is only correct for - * POINTER-originated closes; a keyboard close (Escape / Enter / Space) must keep the - * visible :focus-visible ring or keyboard users lose their location on close. - */ -let lastInputWasKeyboard = false; -if (typeof window !== "undefined") { - window.addEventListener("keydown", () => { lastInputWasKeyboard = true; }, { capture: true, passive: true }); - window.addEventListener("pointerdown", () => { lastInputWasKeyboard = false; }, { capture: true, passive: true }); -} - -/** - * Restore focus to the opener. Pointer closes suppress :focus-visible (avoids the - * sticky double ring after a mouse close); keyboard closes restore plain focus so the - * ring paints. `focusVisible` support is Chrome 145+/FF 104+/Safari 18.4+ — engines - * that ignore the option keep the pre-repair behavior, which the origin tracking - * already makes correct for keyboard users everywhere. - */ -function focusTriggerQuietly(trigger: HTMLButtonElement | null) { - if (!trigger) return; - if (lastInputWasKeyboard) { - trigger.focus({ preventScroll: true }); - return; - } - try { - trigger.focus({ preventScroll: true, focusVisible: false }); - } catch { - trigger.focus({ preventScroll: true }); - } -} - -function useModalDialog(open: boolean, triggerRef: RefObject) { - const dialogRef = useRef(null); - - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - - if (open) { - if (!dialog.open) dialog.showModal(); - return; - } - - if (dialog.open) dialog.close(); - focusTriggerQuietly(triggerRef.current); - }, [open, triggerRef]); - - useEffect(() => () => { - const dialog = dialogRef.current; - if (dialog?.open) dialog.close(); - focusTriggerQuietly(triggerRef.current); - }, [triggerRef]); - - return dialogRef; -} +import { EmptyState } from "../ui"; +import { DashboardDialogs } from "./dashboard-dialogs"; +import { DashboardModelsSection } from "./dashboard-models-section"; +import { DashboardOverviewSection } from "./dashboard-overview-section"; +import { DashboardProvidersSection } from "./dashboard-providers-section"; +import { + dashboardHashForSection, + type DashboardSection, +} from "./dashboard-shared"; +import { useDashboardData } from "./use-dashboard-data"; export default function Dashboard({ apiBase }: { apiBase: string }) { - const { locale, t } = useI18n(); - // The hash is the source of truth for the active section (#dashboard, - // #dashboard/providers, #dashboard/models), so refresh/bookmark/back-forward keep - // the choice. Mirrors the Logs tab contract. - const [selectedSection, setSelectedSection] = useState(readDashboardSectionFromHash); - - useEffect(() => { - const onHash = () => setSelectedSection(readDashboardSectionFromHash()); - window.addEventListener("hashchange", onHash); - return () => window.removeEventListener("hashchange", onHash); - }, []); - const [modelQuery, setModelQuery] = useState(""); - const [expandedProviders, setExpandedProviders] = useState>(new Set()); - const [health, setHealth] = useState(null); - const [startupHealth, setStartupHealth] = useState(null); - const [providers, setProviders] = useState([]); - const [models, setModels] = useState([]); - const [settings, setSettings] = useState(null); - const [sidecar, setSidecar] = useState(null); - const [shadowCall, setShadowCall] = useState(null); - const [usage30d, setUsage30d] = useState(null); - const [sidecarSaving, setSidecarSaving] = useState(false); - const [shadowCallSaving, setShadowCallSaving] = useState(false); - const [modelsLoading, setModelsLoading] = useState(false); - const [settingsSaving, setSettingsSaving] = useState(false); - const [syncing, setSyncing] = useState(false); - const [maMode, setMaMode] = useState<"v1" | "default" | "v2">("default"); - const [maModeResolved, setMaModeResolved] = useState(false); - const [maBusy, setMaBusy] = useState(false); - const [maHelpOpen, setMaHelpOpen] = useState(false); - const [effortCapHelpOpen, setEffortCapHelpOpen] = useState(false); - const [shadowCallHelpOpen, setShadowCallHelpOpen] = useState(false); - const [injectionModel, setInjectionModel] = useState(""); - const [injectionEffort, setInjectionEffort] = useState(""); - const [injectionEfforts, setInjectionEfforts] = useState([]); - const [injectionAvailable, setInjectionAvailable] = useState>([]); - const [injectionSaving, setInjectionSaving] = useState(false); - const [multiAgentGuidanceEnabled, setMultiAgentGuidanceEnabled] = useState(true); - const [effortCap, setEffortCap] = useState(""); - const [subagentEffortCap, setSubagentEffortCap] = useState(""); - const [effortCapSaving, setEffortCapSaving] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const [syncError, setSyncError] = useState(null); - const [projectConfigWarnings, setProjectConfigWarnings] = useState([]); - const [updateOpen, setUpdateOpen] = useState(false); - const [updateChannel, setUpdateChannel] = useState("latest"); - const [updateRestart, setUpdateRestart] = useState(true); - const [updateLoading, setUpdateLoading] = useState(false); - const updateRetryRef = useRef(0); - const updateRetryTimerRef = useRef(null); - const updateRequestEpochRef = useRef(0); - const settingsRequestEpochRef = useRef(0); - const settingsMutationEpochRef = useRef(0); - const settingsMutationInFlightRef = useRef(false); - const shadowCallRequestEpochRef = useRef(0); - const shadowCallMutationEpochRef = useRef(0); - const shadowCallMutationInFlightRef = useRef(false); - const [updateCheck, setUpdateCheck] = useState(null); - const [updateError, setUpdateError] = useState(null); - const [updateJob, setUpdateJob] = useState(null); - const [reconnecting, setReconnecting] = useState(false); - const [error, setError] = useState(false); - const effortCapHelpTriggerRef = useRef(null); - const updateTriggerRef = useRef(null); - const maHelpTriggerRef = useRef(null); - const shadowCallHelpTriggerRef = useRef(null); - const effortCapHelpDialogRef = useModalDialog(effortCapHelpOpen, effortCapHelpTriggerRef); - const updateDialogRef = useModalDialog(updateOpen, updateTriggerRef); - const maHelpDialogRef = useModalDialog(maHelpOpen, maHelpTriggerRef); - const shadowCallHelpDialogRef = useModalDialog(shadowCallHelpOpen, shadowCallHelpTriggerRef); - - useEffect(() => () => { - updateRequestEpochRef.current += 1; - if (updateRetryTimerRef.current !== null) { - window.clearTimeout(updateRetryTimerRef.current); - updateRetryTimerRef.current = null; - } - }, []); - - useEffect(() => { - let cancelled = false; - const readStartupHealth = async () => { - try { - const response = await fetch(`${apiBase}/api/startup-health`); - if (!response.ok) throw new Error("startup health unavailable"); - const data = await response.json() as { status?: unknown; diagnosticStale?: unknown }; - const mapped = mapStartupHealthProbe(data); - if (!mapped) throw new Error("invalid startup health response"); - if (!cancelled) setStartupHealth(mapped); - } catch { - if (!cancelled) setStartupHealth("error"); - } - }; - void readStartupHealth(); - const interval = window.setInterval(() => { void readStartupHealth(); }, 30_000); - return () => { - cancelled = true; - window.clearInterval(interval); - }; - }, [apiBase]); - - useEffect(() => { - const fetchData = async () => { - // Snapshot epochs before issuing fetches so an in-flight poll cannot commit - // after a later mutation or overlapping poll identity change. - const epochs = beginPollEpochs({ - settingsRequest: settingsRequestEpochRef, - settingsMutation: settingsMutationEpochRef, - shadowRequest: shadowCallRequestEpochRef, - shadowMutation: shadowCallMutationEpochRef, - }); - const settingsRequestEpoch = epochs.settings.request; - const settingsMutationEpoch = epochs.settings.mutation; - const shadowRequestEpoch = epochs.shadow.request; - const shadowMutationEpoch = epochs.shadow.mutation; - try { - const [hRes, pRes, sRes, scRes, shRes, uRes] = await Promise.all([ - fetch(`${apiBase}/healthz`), - fetch(`${apiBase}/api/providers`), - fetch(`${apiBase}/api/settings`), - fetch(`${apiBase}/api/sidecar-settings`), - fetch(`${apiBase}/api/shadow-call-settings`), - fetch(`${apiBase}/api/usage?range=30d`), - ]); - setHealth(await hRes.json()); - setProviders(await pRes.json()); - const nextSettings = await sRes.json() as SettingsData; - if (settingsPollMayCommit( - { request: settingsRequestEpoch, mutation: settingsMutationEpoch }, - { - request: settingsRequestEpochRef.current, - mutation: settingsMutationEpochRef.current, - mutationInFlight: settingsMutationInFlightRef.current, - }, - )) { - setSettings(nextSettings); - const seeded = nextSettings.startupHealth; - setStartupHealth(previous => seedStartupHealthFromSettings(previous, seeded)); - } - setSidecar(await scRes.json()); - // Old servers fall through to the SPA HTML for this route; don't let a parse - // failure here take down the whole dashboard. - try { - if (shRes.ok) { - const nextShadow = await shRes.json() as ShadowCallData; - // Ignore polls that raced a user toggle — otherwise the switch flips back - // to the pre-write value for a few seconds until the next poll. - if (settingsPollMayCommit( - { request: shadowRequestEpoch, mutation: shadowMutationEpoch }, - { - request: shadowCallRequestEpochRef.current, - mutation: shadowCallMutationEpochRef.current, - mutationInFlight: shadowCallMutationInFlightRef.current, - }, - )) { - setShadowCall(nextShadow); - } - } - } catch { - // Same epoch gate as success: a parse failure must not null optimistic UI - // while a save is in flight or a newer poll owns the request identity. - if (settingsPollMayCommit( - { request: shadowRequestEpoch, mutation: shadowMutationEpoch }, - { - request: shadowCallRequestEpochRef.current, - mutation: shadowCallMutationEpochRef.current, - mutationInFlight: shadowCallMutationInFlightRef.current, - }, - )) { - setShadowCall(null); - } - } - try { setUsage30d(uRes.ok ? await uRes.json() : null); } catch { setUsage30d(null); } - setError(false); - // Best-effort v2 mode fetch (independent of core health) - try { - const v2Res = await fetch(`${apiBase}/api/v2`); - if (v2Res.ok) { - const v2Data = await v2Res.json(); - if (v2Data.multiAgentMode === "v1" || v2Data.multiAgentMode === "v2") setMaMode(v2Data.multiAgentMode); - else setMaMode("default"); - } - } catch { /* old server */ } - finally { setMaModeResolved(true); } - try { - const imRes = await fetch(`${apiBase}/api/injection-model`); - if (imRes.ok) { - const imData = await imRes.json() as { multiAgentGuidanceEnabled?: boolean; model?: string | null; effort?: string | null; efforts?: string[]; available?: Array<{ provider: string; model: string; namespaced: string }> }; - setMultiAgentGuidanceEnabled(imData.multiAgentGuidanceEnabled !== false); - setInjectionModel(imData.model ?? ""); - setInjectionEffort(imData.effort ?? ""); - setInjectionEfforts(imData.efforts ?? []); - setInjectionAvailable(imData.available ?? []); - } - } catch { /* old server */ } - try { - const ecRes = await fetch(`${apiBase}/api/effort-caps`); - if (ecRes.ok) { - const ecData = await ecRes.json() as { effortCap?: string | null; subagentEffortCap?: string | null; efforts?: string[] }; - setEffortCap(ecData.effortCap ?? ""); - setSubagentEffortCap(ecData.subagentEffortCap ?? ""); - } - } catch { /* old server */ } - } catch { - setError(true); - setMaModeResolved(true); - } - }; - fetchData(); - const interval = setInterval(fetchData, 5000); - return () => { - clearInterval(interval); - settingsRequestEpochRef.current += 1; - shadowCallRequestEpochRef.current += 1; - }; - }, [apiBase]); - - useEffect(() => { - const fetchDiagnostics = async () => { - try { - const pcRes = await fetch(`${apiBase}/api/diagnostics/project-config`); - const pcData = pcRes.ok ? await pcRes.json() as { grouped?: ProjectCodexConfigGroup[] } : null; - setProjectConfigWarnings(pcData?.grouped ?? []); - } catch { - setProjectConfigWarnings([]); - } - }; - void fetchDiagnostics(); - const interval = setInterval(() => void fetchDiagnostics(), PROJECT_CONFIG_DIAGNOSTICS_POLL_MS); - return () => clearInterval(interval); - }, [apiBase]); - - const fetchModels = useCallback(async () => { - setModelsLoading(true); - try { - const response = await fetch(`${apiBase}/api/models`); - setModels(await response.json()); - } catch { - // Keep the previous models list on transient failures. - } finally { - setModelsLoading(false); - } - }, [apiBase]); - - useEffect(() => { - if (error) return; - const timeout = window.setTimeout(() => { - void fetchModels(); - }, 0); - return () => window.clearTimeout(timeout); - }, [error, fetchModels]); - - useEffect(() => { - if (!updateJob?.id || !updateJob.restart) return; - let cancelled = false; - const targetVersion = updateJob.latestVersion; - const poll = async () => { - try { - const res = await fetch(`${apiBase}/api/update/status?jobId=${encodeURIComponent(updateJob.id)}`); - if (res.ok) { - const data = await res.json() as { job?: UpdateJob }; - if (!cancelled && data.job) { - setUpdateJob(data.job); - if (data.job.status === "failed") { - setReconnecting(false); - return; - } - } - } - } catch { - if (!cancelled) setReconnecting(true); - } - - if (!targetVersion) return; - try { - const healthRes = await fetch(`${apiBase}/healthz`, { cache: "no-store" }); - if (!healthRes.ok) throw new Error("health failed"); - const data = await healthRes.json() as HealthData; - if (!cancelled && data.version === targetVersion) { - setReconnecting(false); - window.location.reload(); - } - } catch { - if (!cancelled) setReconnecting(true); - } - }; - poll(); - const interval = setInterval(poll, 1500); - return () => { cancelled = true; clearInterval(interval); }; - }, [apiBase, updateJob?.id, updateJob?.latestVersion, updateJob?.restart]); - - // Group models by provider so the list reads as provider → its models, not one flat wall of cards. - const grouped = useMemo(() => { - const g: Record = {}; - for (const m of models) (g[m.provider] ??= []).push(m); - return Object.entries(g).sort(([a], [b]) => a.localeCompare(b)); - }, [models]); - const filteredGroups = useMemo(() => { - const q = modelQuery.trim().toLowerCase(); - if (!q) return grouped; - const out: Array<[string, ModelInfo[]]> = []; - for (const [provider, rows] of grouped) { - const hits = rows.filter(m => m.id.toLowerCase().includes(q) || provider.toLowerCase().includes(q)); - if (hits.length > 0) out.push([provider, hits]); - } - return out; - }, [grouped, modelQuery]); - const sidecarModels = useMemo(() => sidecarModelOptions(models), [models]); + const d = useDashboardData(apiBase); + const { + t, error, selectedSection, + providers, models, modelsLoading, modelQuery, setModelQuery, + filteredGroups, expandedProviders, setExpandedProviders, + } = d; if (error) { return ( @@ -522,886 +30,21 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { ); } - const online = health?.status === "ok"; - - const saveSidecar = async (patch: SidecarPatch) => { - if (!sidecar || sidecarSaving) return; - const next = { - webSearch: mergeSidecarSetting(sidecar.webSearch, patch.webSearch), - vision: mergeSidecarSetting(sidecar.vision, patch.vision), - }; - setSidecarSaving(true); - setSidecar(next); - try { - const res = await fetch(`${apiBase}/api/sidecar-settings`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), - }); - if (!res.ok) throw new Error("save failed"); - const data = await res.json(); - setSidecar({ webSearch: data.webSearch, vision: data.vision }); - } catch { - setSidecar(sidecar); - } finally { - setSidecarSaving(false); - } - }; - - async function saveShadowCall(patch: Partial) { - if (!shadowCall || shadowCallSaving) return; - const previous = shadowCall; - const updated = { ...shadowCall, ...patch }; - setShadowCallSaving(true); - shadowCallMutationInFlightRef.current = true; - setShadowCall(updated); - try { - const res = await fetch(`${apiBase}/api/shadow-call-settings`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), - }); - if (!res.ok) throw new Error("shadow-call save failed"); - // Bump only after a successful write so a poll that started mid-request - // (still carrying the pre-mutation epoch) cannot overwrite optimistic UI. - shadowCallMutationEpochRef.current += 1; - } catch { - setShadowCall(previous); - } finally { - shadowCallMutationInFlightRef.current = false; - setShadowCallSaving(false); - } - } - - const switchMaMode = async (mode: "v1" | "default" | "v2") => { - if (maBusy || maMode === mode) return; - setMaBusy(true); - try { - const r = await fetch(`${apiBase}/api/v2`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ multiAgentMode: mode }), - }); - if (r.ok) setMaMode(mode); - } catch { /* ignore */ } - finally { setMaBusy(false); } - }; - - const toggleCodexAutoStart = async () => { - if (!settings || settingsSaving) return; - const next = !settings.codexAutoStart; - setSettingsSaving(true); - settingsMutationInFlightRef.current = true; - setSettings({ ...settings, codexAutoStart: next }); - try { - const res = await fetch(`${apiBase}/api/settings`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codexAutoStart: next }), - }); - if (!res.ok) throw new Error("save failed"); - const data = await res.json() as { codexAutoStart: boolean; startupHealth?: SettingsData["startupHealth"] }; - settingsMutationEpochRef.current += 1; - setSettings(prev => prev ? { ...prev, codexAutoStart: data.codexAutoStart, startupHealth: data.startupHealth ?? prev.startupHealth } : prev); - } catch { - setSettings(prev => prev ? { ...prev, codexAutoStart: !next } : prev); - setError(true); - } finally { - settingsMutationInFlightRef.current = false; - setSettingsSaving(false); - } - }; - - const runSync = async () => { - if (syncing) return; - setSyncing(true); - setSyncResult(null); - setSyncError(null); - try { - const res = await fetch(`${apiBase}/api/sync`, { method: "POST" }); - const data = await res.json() as SyncResult | { error?: string }; - if (!res.ok) throw new Error("error" in data && data.error ? data.error : "sync failed"); - setSyncResult(data as SyncResult); - const grouped = (data as SyncResult & { projectConfigGrouped?: ProjectCodexConfigGroup[] }).projectConfigGrouped; - if (grouped) setProjectConfigWarnings(grouped); - } catch (err) { - setSyncError(err instanceof Error ? err.message : String(err)); - } finally { - setSyncing(false); - } - }; - - const fetchUpdateCheck = async (channel: UpdateChannel, resetRetry = false) => { - if (resetRetry) updateRetryRef.current = 0; - if (updateRetryTimerRef.current !== null) { - window.clearTimeout(updateRetryTimerRef.current); - updateRetryTimerRef.current = null; - } - const requestEpoch = ++updateRequestEpochRef.current; - setUpdateLoading(true); - setUpdateError(null); - setUpdateCheck(null); - try { - const res = await fetch(`${apiBase}/api/update/check?tag=${channel}`); - const data = await res.json() as UpdateCheckData | { error?: string }; - if (!res.ok) throw new Error("error" in data && data.error ? data.error : "update check failed"); - if (requestEpoch !== updateRequestEpochRef.current) return; - - const check = data as UpdateCheckData; - setUpdateCheck(check); - if ( - check.reason === "latest_unavailable" - && updateRetryRef.current < UPDATE_CHECK_MAX_AUTO_RETRIES - ) { - const retry = ++updateRetryRef.current; - updateRetryTimerRef.current = window.setTimeout(() => { - if (requestEpoch !== updateRequestEpochRef.current) return; - updateRetryTimerRef.current = null; - void fetchUpdateCheck(channel); - }, UPDATE_CHECK_RETRY_BASE_MS * retry); - return; - } - - if (check.reason !== "latest_unavailable") updateRetryRef.current = 0; - setUpdateLoading(false); - } catch (err) { - if (requestEpoch !== updateRequestEpochRef.current) return; - setUpdateError(err instanceof Error ? err.message : String(err)); - setUpdateLoading(false); - } - }; - - const closeUpdateDialog = () => { - updateRequestEpochRef.current += 1; - if (updateRetryTimerRef.current !== null) { - window.clearTimeout(updateRetryTimerRef.current); - updateRetryTimerRef.current = null; - } - setUpdateLoading(false); - setUpdateOpen(false); - }; - - const openUpdateDialog = () => { - const channel = defaultUpdateChannel(health?.version); - setUpdateChannel(channel); - setUpdateRestart(true); - setUpdateOpen(true); - void fetchUpdateCheck(channel, true); - }; - - const changeUpdateChannel = (channel: UpdateChannel) => { - setUpdateChannel(channel); - void fetchUpdateCheck(channel, true); - }; - - const runUpdate = async () => { - if (!updateCheck?.canUpdate) return; - setUpdateError(null); - try { - const res = await fetch(`${apiBase}/api/update/run`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tag: updateChannel, restart: updateRestart }), - }); - const data = await res.json() as { job?: UpdateJob; error?: string }; - if (!res.ok || !data.job) throw new Error(data.error ?? "update failed to start"); - setUpdateJob(data.job); - setReconnecting(false); - closeUpdateDialog(); - } catch (err) { - setUpdateError(err instanceof Error ? err.message : String(err)); - } - }; - - const overviewSection = ( -
-
-
-
-
- {t("dash.multiAgent")} - -
-
-
- {(["v1", "default", "v2"] as const).map(mode => ( - - ))} -
-
-
-
-
{t("dash.status")}
-
- {online ? t("dash.online") : t("dash.offline")} -
-
-
{t("dash.version")}
{health?.version ?? "—"}
-
{t("dash.uptime")}
{health ? formatUptime(health.uptime, locale) : "—"}
-
{t("dash.providers")}
{providers.length}
-
-
{t("dash.tokens30d")}
-
{usage30d && usage30d.summary.requests > 0 ? formatTokens(usage30d.summary.totalTokens, locale) : "—"}
-
- {usage30d && usage30d.summary.requests > 0 - ? t("dash.coverage").replace("{pct}", `${Math.round(usage30d.summary.coverageRatio * 100)}%`) - : "\u00a0"} -
-
-
- - -
- -{projectConfigWarnings.length > 0 && ( -
- -
-
{t("dash.projectConfigTitle")}
-
{t("dash.projectConfigHint")}
-
    - {projectConfigWarnings.map(g => ( -
  • - {g.path} — {g.issues.join(", ")} -
    {g.bypass}
    -
  • - ))} -
-
-
-)} - -{maModeResolved && maMode !== "v1" && ( -
-
- - {t("dash.effortCapLabel")} - - - ({ value: e, label: e })), - ]} - onChange={async (v) => { - if (effortCapSaving) return; - setEffortCapSaving(true); - try { - const res = await fetch(`${apiBase}/api/effort-caps`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ subagentEffortCap: v || null }), - }); - if (res.ok) { - const data = await res.json() as { ok: boolean; effortCap?: string | null; subagentEffortCap?: string | null }; - setEffortCap(data.effortCap ?? ""); - setSubagentEffortCap(data.subagentEffortCap ?? ""); - } - } catch { /* ignore */ } - finally { setEffortCapSaving(false); } - }} - disabled={effortCapSaving} - label={t("dash.subagentEffortCapLabel")} - /> -
-
-)} - -
-
- {t("dash.injectionLabel")} - ({ value: e, label: e })), - ]} - onChange={async (v) => { - if (injectionSaving) return; - setInjectionSaving(true); - try { - const res = await fetch(`${apiBase}/api/injection-model`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: injectionModel || null, effort: v || null }), - }); - if (res.ok) { - const data = await res.json() as { model?: string | null; effort?: string | null }; - setInjectionModel(data.model ?? ""); - setInjectionEffort(data.effort ?? ""); - } - } catch { /* ignore */ } - finally { setInjectionSaving(false); } - }} - disabled={injectionSaving || !multiAgentGuidanceEnabled} - label={t("dash.injectionEffortLabel")} - /> - )} - {injectionModel && multiAgentGuidanceEnabled && {t("dash.injectionActive")}} -
-
{t("dash.injectionHint")}
-
-
-
{t("dash.multiAgentGuidance")}
-
{t("dash.multiAgentGuidanceHint")}
-
- -
-
- -
-
-
-
{t("dash.maintenance")}
-
{t("dash.maintenanceHint")}
-
-
- - -
-
- {syncResult && ( -
- - - {t("dash.syncOk", { count: syncResult.added })} - {syncResult.warning ? ` ${syncResult.warning}` : ""} - {syncResult.staleAppServerHint ? ` ${t("dash.syncStaleHint")}` : ""} - -
- )} - {syncError && ( -
- {t("dash.syncFailed", { error: syncError })} -
- )} - {updateJob && ( -
- {updateJob.status === "failed" ? : } - - {updateJobLabel(updateJob.status, t)} - {updateJob.latestVersion ? ` ${updateJob.currentVersion} -> ${updateJob.latestVersion}.` : ""} - {reconnecting ? ` ${t("dash.updateReconnecting")}` : ""} - {updateJob.error ? ` ${updateJob.error}` : ""} - -
- )} -
- -
-
-
-
{t("dash.codexAutoStart")}
-
{t("dash.codexAutoStartHint")}
-
- -
-
- -
-
-
-
{t("dash.webSearchSidecar")}
- { void saveSidecar({ vision: { model, backend: sidecarBackendForModel(models, model) } }); }} - disabled={!sidecar || sidecarSaving} - label={t("dash.sidecarModel")} - /> -
-
{t("dash.visionSidecarHint")}
-
-
- -
-
-
- {t("dash.shadowCallIntercept")} - - ⚠ 5.4-mini -
-
- - setModelQuery(e.target.value)} - aria-label={t("models.search")} + -
- {filteredGroups.length === 0 ? ( -

{t("dash.modelsNoResults")}

- ) : ( -
- {filteredGroups.map(([provider, rows]) => { - const q = modelQuery.trim().toLowerCase(); - const open = q !== "" || expandedProviders.has(provider); - return ( -
- - {open && ( -
- {rows.map(m => ( - {m.id} - ))} -
- )} -
- ); - })} -
- )} - -)} - - - ); - - const updateDialog = ( - <> - { event.preventDefault(); closeUpdateDialog(); }} -> -
-
-

{t("dash.updateTitle")}

- -
-
{t("dash.updateDesc")}
-
- - changeUpdateChannel(v as UpdateChannel)} + disabled={updateLoading} + label={t("dash.updateChannel")} + portal={false} + /> +
+ {updateLoading && } title={t("dash.updateChecking")} />} + {updateError && ( +
{updateError}
+ )} + {updateCheck && !updateLoading && ( +
+
+
+
{t("dash.updateInstalled")}
+
{updateCheck.currentVersion}
+
+
+
{t("dash.updateLatest")}
+
{updateCheck.latestVersion ?? "—"}
+
+ + {updateCheck.updateAvailable ? t("dash.updateAvailable") : t("dash.updateCurrent")} + +
+
{t("dash.updateCommand")} {updateCheck.command}
+ {updateCheck.reason === "source_checkout" && ( +
{t("dash.updateSource")}
+ )} + {updateCheck.reason === "latest_unavailable" && ( +
+ {t("dash.updateUnavailable")} + +
+ )} + {!updateCheck.canUpdate && updateCheck.reason !== "latest_unavailable" && updateCheck.reason !== "source_checkout" && ( +
+ + {t("dash.updateCannotAuto", { reason: updateReasonLabel(updateCheck.reason, t) })} + + +
+ )} + {updateCheck.canUpdate && ( +
+
+
{t("dash.updateRestart")}
+
{t("dash.updateRestartHint")}
+
+ +
+ )} +
+ )} +
+ + +
+
+
+ + { event.preventDefault(); setMaHelpOpen(false); }} + onClick={event => { if (event.target === event.currentTarget) setMaHelpOpen(false); }} + > +
e.stopPropagation()}> +
+

{t("dash.multiAgent")}

+ +
+
+ {t("models.v2Help")} +
+ +
+ +
+
+
+ + { event.preventDefault(); setEffortCapHelpOpen(false); }} + onClick={event => { if (event.target === event.currentTarget) setEffortCapHelpOpen(false); }} + > +
e.stopPropagation()}> +
+

{t("dash.effortCapLabel")}

+ +
+
+ {t("dash.effortCapHelp")} +
+
+ +
+
+
+ + { event.preventDefault(); setShadowCallHelpOpen(false); }} + onClick={event => { if (event.target === event.currentTarget) setShadowCallHelpOpen(false); }} + > +
e.stopPropagation()}> +
+

{t("dash.shadowCallIntercept")}

+ +
+
+ {t("dash.shadowCallTooltip")} +
+
+ +
+
+
+ + ); +} diff --git a/gui/src/pages/dashboard-models-section.tsx b/gui/src/pages/dashboard-models-section.tsx new file mode 100644 index 0000000000..a3d2f1af2a --- /dev/null +++ b/gui/src/pages/dashboard-models-section.tsx @@ -0,0 +1,82 @@ +import { type Dispatch, type SetStateAction } from "react"; +import { IconChevron, IconSearch } from "../icons"; +import type { TFn } from "../i18n/shared"; +import { EmptyState } from "../ui"; +import type { ModelInfo } from "./dashboard-shared"; + +export function DashboardModelsSection({ + t, + models, + modelsLoading, + modelQuery, + setModelQuery, + filteredGroups, + expandedProviders, + setExpandedProviders, +}: { + t: TFn; + models: ModelInfo[]; + modelsLoading: boolean; + modelQuery: string; + setModelQuery: (v: string) => void; + filteredGroups: Array<[string, ModelInfo[]]>; + expandedProviders: Set; + setExpandedProviders: Dispatch>>; +}) { + return ( + <> +
+ {t("dash.availableModels")} {models.length} + {modelsLoading && } +
+ {models.length === 0 && !modelsLoading ? ( + + ) : ( + <> +
+
+ {filteredGroups.length === 0 ? ( +

{t("dash.modelsNoResults")}

+ ) : ( +
+ {filteredGroups.map(([provider, rows]) => { + const q = modelQuery.trim().toLowerCase(); + const open = q !== "" || expandedProviders.has(provider); + return ( +
+ + {open && ( +
+ {rows.map(m => ( + {m.id} + ))} +
+ )} +
+ ); + })} +
+ )} + + )} + + ); +} diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx new file mode 100644 index 0000000000..87293ae51d --- /dev/null +++ b/gui/src/pages/dashboard-overview-head.tsx @@ -0,0 +1,126 @@ +import { IconAlert, IconInfo } from "../icons"; +import { type TKey, useT } from "../i18n/shared"; +import { formatTokens } from "../format-tokens"; +import { formatUptime } from "../formatUptime"; +import type { useDashboardData } from "./use-dashboard-data"; + +type Dash = ReturnType; + +export function DashboardOverviewHead({ + locale, + health, + providers, + usage30d, + startupHealth, + projectConfigWarnings, + maMode, + maBusy, + maHelpTriggerRef, + maHelpOpen, + setMaHelpOpen, + switchMaMode, +}: Pick) { + const t = useT(); + const online = health?.status === "ok"; + + return ( + <> +
+
+
+
+ {t("dash.multiAgent")} + +
+
+
+ {(["v1", "default", "v2"] as const).map(mode => ( + + ))} +
+
+
+
+
{t("dash.status")}
+
+ {online ? t("dash.online") : t("dash.offline")} +
+
+
{t("dash.version")}
{health?.version ?? "—"}
+
{t("dash.uptime")}
{health ? formatUptime(health.uptime, locale) : "—"}
+
{t("dash.providers")}
{providers.length}
+
+
{t("dash.tokens30d")}
+
{usage30d && usage30d.summary.requests > 0 ? formatTokens(usage30d.summary.totalTokens, locale) : "—"}
+
+ {usage30d && usage30d.summary.requests > 0 + ? t("dash.coverage").replace("{pct}", `${Math.round(usage30d.summary.coverageRatio * 100)}%`) + : "\u00a0"} +
+
+
+ + +
+ + {projectConfigWarnings.length > 0 && ( +
+ +
+
{t("dash.projectConfigTitle")}
+
{t("dash.projectConfigHint")}
+
    + {projectConfigWarnings.map(g => ( +
  • + {g.path} — {g.issues.join(", ")} +
    {g.bypass}
    +
  • + ))} +
+
+
+ )} + + ); +} diff --git a/gui/src/pages/dashboard-overview-panels.tsx b/gui/src/pages/dashboard-overview-panels.tsx new file mode 100644 index 0000000000..0dd3aad3a1 --- /dev/null +++ b/gui/src/pages/dashboard-overview-panels.tsx @@ -0,0 +1,22 @@ +import MemoryObservabilityCard from "../components/MemoryObservabilityCard"; +import type { useDashboardData } from "./use-dashboard-data"; +import { + DashboardEffortCapPanel, + DashboardInjectionPanel, + DashboardMaintenancePanel, + DashboardSidecarPanels, +} from "./dashboard-overview-sections"; + +type Dash = ReturnType; + +export function DashboardOverviewPanels(props: Dash) { + return ( + <> + + + + + + + ); +} diff --git a/gui/src/pages/dashboard-overview-section.tsx b/gui/src/pages/dashboard-overview-section.tsx new file mode 100644 index 0000000000..4e20772abf --- /dev/null +++ b/gui/src/pages/dashboard-overview-section.tsx @@ -0,0 +1,14 @@ +import { DashboardOverviewHead } from "./dashboard-overview-head"; +import { DashboardOverviewPanels } from "./dashboard-overview-panels"; +import type { useDashboardData } from "./use-dashboard-data"; + +type Dash = ReturnType; + +export function DashboardOverviewSection(d: Dash) { + return ( +
+ + +
+ ); +} diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx new file mode 100644 index 0000000000..897df10257 --- /dev/null +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -0,0 +1,350 @@ +import { IconAlert, IconExternal, IconInfo, IconRefresh } from "../icons"; +import { readJsonOrThrow } from "../fetch-json"; +import { Select } from "../ui"; +import { EFFORT_CAP_LEVELS, sidecarBackendForModel, updateJobLabel } from "./dashboard-shared"; +import type { useDashboardData } from "./use-dashboard-data"; + +type Dash = ReturnType; + +export function DashboardEffortCapPanel({ apiBase, d }: { apiBase: string; d: Dash }) { + const { + t, maMode, maModeResolved, + effortCapHelpTriggerRef, effortCapHelpOpen, setEffortCapHelpOpen, + effortCap, subagentEffortCap, effortCapSaving, setEffortCap, setSubagentEffortCap, setEffortCapSaving, + } = d; + + if (!maModeResolved || maMode === "v1") return null; + + return ( +
+
+ + {t("dash.effortCapLabel")} + + + ({ value: e, label: e })), + ]} + onChange={async (v) => { + if (effortCapSaving) return; + setEffortCapSaving(true); + try { + const res = await fetch(`${apiBase}/api/effort-caps`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subagentEffortCap: v || null }), + }); + const data = await readJsonOrThrow<{ ok: boolean; effortCap?: string | null; subagentEffortCap?: string | null }>(res); + setEffortCap(data.effortCap ?? ""); + setSubagentEffortCap(data.subagentEffortCap ?? ""); + } catch { /* ignore */ } + finally { setEffortCapSaving(false); } + }} + disabled={effortCapSaving} + label={t("dash.subagentEffortCapLabel")} + /> +
+
+ ); +} + +export function DashboardInjectionPanel({ apiBase, d }: { apiBase: string; d: Dash }) { + const { + t, + injectionModel, injectionEffort, injectionEfforts, injectionAvailable, injectionSaving, + setInjectionModel, setInjectionEffort, setInjectionSaving, + multiAgentGuidanceEnabled, setMultiAgentGuidanceEnabled, + } = d; + + return ( +
+
+ {t("dash.injectionLabel")} + ({ value: e, label: e })), + ]} + onChange={async (v) => { + if (injectionSaving) return; + setInjectionSaving(true); + try { + const res = await fetch(`${apiBase}/api/injection-model`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: injectionModel || null, effort: v || null }), + }); + const data = await readJsonOrThrow<{ model?: string | null; effort?: string | null }>(res); + setInjectionModel(data.model ?? ""); + setInjectionEffort(data.effort ?? ""); + } catch { /* ignore */ } + finally { setInjectionSaving(false); } + }} + disabled={injectionSaving || !multiAgentGuidanceEnabled} + label={t("dash.injectionEffortLabel")} + /> + )} + {injectionModel && multiAgentGuidanceEnabled && {t("dash.injectionActive")}} +
+
{t("dash.injectionHint")}
+
+
+
{t("dash.multiAgentGuidance")}
+
{t("dash.multiAgentGuidanceHint")}
+
+ +
+
+ ); +} + +export function DashboardMaintenancePanel({ d }: { d: Dash }) { + const { + t, runSync, syncing, updateTriggerRef, openUpdateDialog, updateLoading, updateOpen, + syncResult, syncError, updateJob, reconnecting, + } = d; + + return ( +
+
+
+
{t("dash.maintenance")}
+
{t("dash.maintenanceHint")}
+
+
+ + +
+
+ {syncResult && ( +
+ + + {t("dash.syncOk", { count: syncResult.added })} + {syncResult.warning ? ` ${syncResult.warning}` : ""} + {syncResult.staleAppServerHint ? ` ${t("dash.syncStaleHint")}` : ""} + +
+ )} + {syncError && ( +
+ {t("dash.syncFailed", { error: syncError })} +
+ )} + {updateJob && ( +
+ {updateJob.status === "failed" ? : } + + {updateJobLabel(updateJob.status, t)} + {updateJob.latestVersion ? ` ${updateJob.currentVersion} -> ${updateJob.latestVersion}.` : ""} + {reconnecting ? ` ${t("dash.updateReconnecting")}` : ""} + {updateJob.error ? ` ${updateJob.error}` : ""} + +
+ )} +
+ ); +} + +export function DashboardSidecarPanels({ d }: { d: Dash }) { + const { + t, settings, settingsSaving, toggleCodexAutoStart, + sidecar, sidecarSaving, sidecarModels, models, saveSidecar, + shadowCall, shadowCallSaving, shadowCallHelpTriggerRef, shadowCallHelpOpen, setShadowCallHelpOpen, saveShadowCall, + } = d; + + return ( + <> +
+
+
+
{t("dash.codexAutoStart")}
+
{t("dash.codexAutoStartHint")}
+
+ +
+
+ +
+
+
+
{t("dash.webSearchSidecar")}
+ { void saveSidecar({ vision: { model, backend: sidecarBackendForModel(models, model) } }); }} + disabled={!sidecar || sidecarSaving} + label={t("dash.sidecarModel")} + /> +
+
{t("dash.visionSidecarHint")}
+
+
+ +
+
+
+ {t("dash.shadowCallIntercept")} + + ⚠ 5.4-mini +
+
+ +