Skip to content
Open
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
Binary file added docs/pr-assets/config-divergence-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export const de: Record<TKey, string> = {
"dash.providers": "Anbieter",
"dash.tokens30d": "Tokens (30d)",
"dash.coverage": "{pct} Abdeckung",
"dash.configDiverged": "config.json wurde nach dem Laden durch den Proxy extern geändert — kein automatisches Neuladen; Neustart zum Anwenden",
"dash.mem.title": "Speicherbeobachtung",
"dash.mem.hint": "Schreibgeschützte Laufzeitdiagnose. Beobachteter Speicher ist max(RSS, external, ArrayBuffers), damit Windows-Working-Set-Trimming gebundenen Speicher nicht versteckt.",
"dash.mem.rss": "Resident Set (RSS)",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export const en = {
"dash.providers": "Providers",
"dash.tokens30d": "Tokens (30d)",
"dash.coverage": "{pct} coverage",
"dash.configDiverged": "config.json changed on disk after the proxy loaded it — no automatic reload; restart to apply",
// memory observability card (read-only /api/system/memory)
"dash.mem.title": "Memory observability",
"dash.mem.hint": "Read-only runtime diagnostics. Observed memory is max(RSS, external, ArrayBuffers) so Windows working-set trimming does not hide committed retention.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ export const fr: Record<TKey, string> = {
"dash.providers": "Fournisseurs",
"dash.tokens30d": "Jetons (30 j)",
"dash.coverage": "Couverture : {pct}",
"dash.configDiverged": "config.json a changé sur le disque après le chargement du proxy — pas de rechargement automatique ; redémarrez pour appliquer",
"dash.mem.title": "Observabilité de la mémoire",
"dash.mem.hint": "Diagnostics d’exécution en lecture seule. La mémoire observée correspond à max(RSS, external, ArrayBuffers), afin que la réduction de l’ensemble de travail Windows ne masque pas la rétention allouée.",
"dash.mem.rss": "Ensemble résident (RSS)",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export const ja: Record<TKey, string> = {
"dash.providers": "プロバイダー",
"dash.tokens30d": "トークン (30日)",
"dash.coverage": "{pct} カバレッジ",
"dash.configDiverged": "config.json がプロキシ読み込み後に変更されました — 自動再読み込みはされません。再起動して適用",
"dash.mem.title": "メモリ可観測性",
"dash.mem.hint": "読み取り専用のランタイム診断。観測メモリは max(RSS, external, ArrayBuffers) で、Windows の working set trimming がコミット済み保持を隠さないようにします。",
"dash.mem.rss": "常駐メモリ (RSS)",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export const ko: Record<TKey, string> = {
"dash.providers": "프로바이더",
"dash.tokens30d": "토큰 (30일)",
"dash.coverage": "커버리지 {pct}",
"dash.configDiverged": "config.json이 프록시 로드 후 변경되었습니다 — 자동 재로드되지 않습니다. 재시작하여 적용",
"dash.mem.title": "메모리 관찰",
"dash.mem.hint": "읽기 전용 런타임 진단. 관측 메모리는 max(RSS, external, ArrayBuffers)라 Windows working set trimming이 커밋된 보존 메모리를 숨기지 못합니다.",
"dash.mem.rss": "상주 메모리 (RSS)",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export const ru: Record<TKey, string> = {
"dash.providers": "Провайдеры",
"dash.tokens30d": "Токены (30 дн.)",
"dash.coverage": "{pct} покрытия",
"dash.configDiverged": "config.json изменился на диске после загрузки прокси — автоперезагрузки нет; перезапустите для применения",
"dash.mem.title": "Наблюдение за памятью",
"dash.mem.hint": "Диагностика среды выполнения только для чтения. Наблюдаемая память — max(RSS, external, ArrayBuffers), чтобы trimming рабочего набора Windows не скрывал удержанную память.",
"dash.mem.rss": "Резидентная память (RSS)",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export const tr: Record<TKey, string> = {
"dash.providers": "Sağlayıcılar",
"dash.tokens30d": "Jetonlar (30 gün)",
"dash.coverage": "%{pct} kapsam",
"dash.configDiverged": "config.json, proxy yüklendikten sonra diskte değişti — otomatik yeniden yükleme yok; uygulamak için yeniden başlatın",
"dash.mem.title": "Bellek izlenebilirliği",
"dash.mem.hint": "Salt okunur çalışma zamanı tanılamaları. Gözlemlenen bellek max(RSS, harici, ArrayBuffers) değeridir.",
"dash.mem.rss": "Yerleşik küme (RSS)",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const zhTW: Record<TKey, string> = {
"dash.providers": "供應商",
"dash.tokens30d": "Token (30 天)",
"dash.coverage": "覆蓋率 {pct}",
"dash.configDiverged": "config.json 在代理載入後被修改 — 不會自動重載;請重啟後生效",
"dash.mem.title": "記憶體可觀測性",
"dash.mem.hint": "只讀執行時診斷。觀測記憶體為 max(RSS, external, ArrayBuffers),避免 Windows working set trimming 隱藏已提交的保留記憶體。",
"dash.mem.rss": "常駐記憶體 (RSS)",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export const zh: Record<TKey, string> = {
"dash.providers": "提供方",
"dash.tokens30d": "Token (30 天)",
"dash.coverage": "覆盖率 {pct}",
"dash.configDiverged": "config.json 在代理加载后被修改 — 不会自动重载;请重启后生效",
"dash.mem.title": "内存可观测性",
"dash.mem.hint": "只读运行时诊断。观测内存为 max(RSS, external, ArrayBuffers),避免 Windows working set trimming 隐藏已提交的保留内存。",
"dash.mem.rss": "常驻内存 (RSS)",
Expand Down
48 changes: 48 additions & 0 deletions gui/src/pages/dashboard-core-poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ export type DashboardSettingsPoll = {
startupHealthSeed: SettingsData["startupHealth"] | null | undefined;
};

export type DashboardConfigStatusPoll = {
/** Null when the endpoint is unavailable (older server) or the fetch failed. */
configDivergence: {
available: boolean;
residentVersion: string | null;
diskVersion: string | null;
diverged: boolean;
} | null;
};

export type DashboardMaModePoll = {
maMode: "v1" | "default" | "v2";
};
Expand Down Expand Up @@ -248,6 +258,44 @@ export async function fetchDashboardMaMode(
}
}

/**
* Resident-vs-disk config divergence from the running proxy. Older servers without
* /api/config/status report null; the dashboard must keep showing the normal state.
*/
export async function fetchDashboardConfigStatus(
apiBase: string,
signal: AbortSignal,
): Promise<DashboardConfigStatusPoll> {
try {
const response = await fetch(`${apiBase}/api/config/status`, { signal });
if (!response.ok) return { configDivergence: null };
const data = await response.json() as {
residentVersion?: unknown;
diskVersion?: unknown;
diverged?: unknown;
};
const residentVersion = data.residentVersion ?? null;
const diskVersion = data.diskVersion ?? null;
const diverged = data.diverged;
if (
!(residentVersion === null || typeof residentVersion === "string")
|| !(diskVersion === null || typeof diskVersion === "string")
|| typeof diverged !== "boolean"
) return { configDivergence: null };
return {
configDivergence: {
available: true,
residentVersion,
diskVersion,
diverged,
},
};
} catch (error) {
if (isAbortError(error, signal)) throw error;
return { configDivergence: null };
}
}

export async function fetchDashboardOverview(
apiBase: string,
signal: AbortSignal,
Expand Down
13 changes: 11 additions & 2 deletions gui/src/pages/dashboard-overview-head.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ export function DashboardOverviewHead({
healthLoading,
startupHealth,
projectConfigWarnings,
configDivergence,
maMode,
maBusy,
maHelpTriggerRef,
maHelpOpen,
setMaHelpOpen,
switchMaMode,
switchMaMode,
maError,
}: Pick<Dash, "locale" | "health" | "providers" | "usage30d" | "usageLoading" | "healthLoading" | "startupHealth" | "projectConfigWarnings" | "maMode" | "maBusy" | "maHelpTriggerRef" | "maHelpOpen" | "setMaHelpOpen" | "switchMaMode" | "maError">) {
}: Pick<Dash, "locale" | "health" | "providers" | "usage30d" | "usageLoading" | "healthLoading" | "startupHealth" | "projectConfigWarnings" | "configDivergence" | "maMode" | "maBusy" | "maHelpTriggerRef" | "maHelpOpen" | "setMaHelpOpen" | "switchMaMode" | "maError">) {
const t = useT();
const online = health?.status === "ok";

Expand Down Expand Up @@ -111,6 +112,14 @@ export function DashboardOverviewHead({
</div>
)}
</div>
{configDivergence?.available === true && configDivergence.diverged && (
<div className="startup-health-slot" role="alert">
<button type="button" className="startup-health-bar" onClick={() => navigateHash("settings")}>
<span className="dot dot-amber" aria-hidden="true" />
<span className="startup-health-bar__summary">{t("dash.configDiverged")}</span>
</button>
</div>
)}
</div>

{projectConfigWarnings.length > 0 && (
Expand Down
16 changes: 16 additions & 0 deletions gui/src/pages/use-dashboard-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import {
fetchDashboardSettings,
fetchDashboardSidecars,
fetchDashboardUsage,
fetchDashboardConfigStatus,
fetchProjectConfigDiagnostics,
fetchStartupHealth,
normalizeInjectionSelection,
type DashboardEpochRefs,
type DashboardConfigStatusPoll,
} from "./dashboard-core-poll";
import { usageSummary30dResourceKey } from "../usage-summary-resource";
import {
Expand Down Expand Up @@ -124,6 +126,7 @@ export function useDashboardData(apiBase: string) {
const [settingsSaving, setSettingsSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [maMode, setMaMode] = useState<MaMode>(() => cachedMaMode ?? "default");
const [configDivergence, setConfigDivergence] = useState<DashboardConfigStatusPoll["configDivergence"]>(null);
const [maBusy, setMaBusy] = useState(false);
const [maError, setMaError] = useState<string | null>(null);
const [maHelpOpen, setMaHelpOpen] = useState(false);
Expand Down Expand Up @@ -234,6 +237,13 @@ export function useDashboardData(apiBase: string) {
{ pollMs: 5000 },
);

const configStatusPoll = useKeyedClientResource(
`dashboard-config-status:${apiBase}`,
[apiBase],
(signal) => fetchDashboardConfigStatus(apiBase, signal),
{ pollMs: 15000 },
);

const sidecarPoll = useKeyedClientResource(
`dashboard-sidecars:${apiBase}`,
[apiBase],
Expand Down Expand Up @@ -324,6 +334,11 @@ export function useDashboardData(apiBase: string) {
writeSessionListCache(`${MA_MODE_CACHE_PREFIX}${apiBase}`, maModePoll.data.maMode);
}, [maModePoll.data, apiBase]);

useEffect(() => {
if (configStatusPoll.data === undefined) return;
setConfigDivergence(configStatusPoll.data.configDivergence);
}, [configStatusPoll.data]);

// Derived — avoids setState-on-prop-change for the resolved flag. Cache / poll / optimistic
// save (which writes the same cache key) all count as resolved for MA UI.
const maModeResolved = maModePoll.data !== undefined || cachedMaMode !== null;
Expand Down Expand Up @@ -774,6 +789,7 @@ export function useDashboardData(apiBase: string) {
modelQuery, setModelQuery,
expandedProviders, setExpandedProviders,
health, startupHealth, providers, models, settings, sidecar, shadowCall, usage30d,
configDivergence,
usageLoading: usagePoll.loading && !usage30d,
healthLoading: overviewPoll.loading && !health,
sidecarSaving, shadowCallSaving, modelsLoading, settingsSaving, syncing,
Expand Down
84 changes: 84 additions & 0 deletions gui/tests/dashboard-contracts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { expect, test } from "bun:test";
import { createElement, type ComponentProps } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { en } from "../src/i18n/en";
import { I18nContext, type I18nContextValue } from "../src/i18n/shared";
import { normalizeInjectionSelection } from "../src/pages/dashboard-core-poll";
import { DashboardOverviewHead } from "../src/pages/dashboard-overview-head";
import { PROJECT_CONFIG_DIAGNOSTICS_POLL_MS, beginPollEpoch, beginPollEpochs } from "../src/startup-health-ui";

test("project-config diagnostics poll cadence is owned by the shared constant", () => {
Expand Down Expand Up @@ -179,6 +183,86 @@ test("fetchStartupHealth does not map abort into a sticky error status", async (
}
});

test("dashboard config-divergence status is fetched from /api/config/status and wired into the hook", async () => {
const core = await Bun.file(new URL("../src/pages/dashboard-core-poll.ts", import.meta.url)).text();
const hook = await Bun.file(new URL("../src/pages/use-dashboard-data.ts", import.meta.url)).text();
const head = await Bun.file(new URL("../src/pages/dashboard-overview-head.tsx", import.meta.url)).text();
expect(core).toContain("/api/config/status");
expect(core).toContain("export async function fetchDashboardConfigStatus");
expect(hook).toContain("fetchDashboardConfigStatus(apiBase, signal)");
expect(hook).toContain("configStatusPoll");
expect(hook).toContain("configDivergence,");
expect(head).toContain("configDivergence?.available === true");
expect(head).toContain("dash.configDiverged");
});

test("fetchDashboardConfigStatus normalizes diverged and tolerates old servers", async () => {
const { fetchDashboardConfigStatus } = await import("../src/pages/dashboard-core-poll");
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async () => Response.json({ residentVersion: "a", diskVersion: "b", diverged: true })) as typeof fetch;
await expect(fetchDashboardConfigStatus("http://test", new AbortController().signal)).resolves.toEqual({
configDivergence: { available: true, residentVersion: "a", diskVersion: "b", diverged: true },
});
globalThis.fetch = (async () => new Response("nope", { status: 404 })) as typeof fetch;
await expect(fetchDashboardConfigStatus("http://test", new AbortController().signal)).resolves.toEqual({ configDivergence: null });

// Malformed successful payloads must not be trusted as available data.
globalThis.fetch = (async () => Response.json({})) as typeof fetch;
await expect(fetchDashboardConfigStatus("http://test", new AbortController().signal)).resolves.toEqual({ configDivergence: null });
globalThis.fetch = (async () => Response.json({ residentVersion: "a", diskVersion: "b", diverged: "yes" })) as typeof fetch;
await expect(fetchDashboardConfigStatus("http://test", new AbortController().signal)).resolves.toEqual({ configDivergence: null });
} finally {
globalThis.fetch = originalFetch;
}
});

test("dashboard config-divergence alert renders from a fake /api/config/status response", async () => {
const { fetchDashboardConfigStatus } = await import("../src/pages/dashboard-core-poll");
const originalFetch = globalThis.fetch;
const t: I18nContextValue["t"] = key => key;
const i18n: I18nContextValue = { locale: "en", setLocale: () => {}, t };
const baseProps = {
locale: "en",
health: { status: "ok", version: "9.9.9", uptime: 60 },
providers: [],
usage30d: null,
usageLoading: false,
healthLoading: false,
startupHealth: "protected",
projectConfigWarnings: [],
maMode: "v1",
maBusy: false,
maHelpTriggerRef: { current: null },
maHelpOpen: false,
setMaHelpOpen: () => {},
switchMaMode: async () => {},
maError: null,
} satisfies ComponentProps<typeof DashboardOverviewHead>;
try {
globalThis.fetch = (async () => Response.json({ residentVersion: "a", diskVersion: "b", diverged: true })) as typeof fetch;
const { configDivergence } = await fetchDashboardConfigStatus("http://test", new AbortController().signal);
const html = renderToStaticMarkup(
createElement(I18nContext.Provider, { value: i18n },
createElement(DashboardOverviewHead, { ...baseProps, configDivergence })),
);
expect(html).toContain("dash.configDiverged");
expect(html).toContain("role=\"alert\"");

// A settled (non-diverged) answer must not render the alert.
globalThis.fetch = (async () => Response.json({ residentVersion: "a", diskVersion: "a", diverged: false })) as typeof fetch;
const settled = await fetchDashboardConfigStatus("http://test", new AbortController().signal);
const settledHtml = renderToStaticMarkup(
createElement(I18nContext.Provider, { value: i18n },
createElement(DashboardOverviewHead, { ...baseProps, configDivergence: settled.configDivergence })),
);
expect(settledHtml).not.toContain("dash.configDiverged");
} finally {
globalThis.fetch = originalFetch;
}
});


// The chip used to sit on the server's conservative placeholder until the next 30s tick, which is
// why an unrelated action (refresh quota, tab hop) looked like the thing that fixed it. The probe
// has to carry `stale` through so the caller can re-ask in seconds.
Expand Down
4 changes: 4 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,10 @@ async function handleStatus() {
console.log(` ⚠️ ${status.json.codexHome.warning}`);
console.log(` Action: ${status.json.codexHome.action}`);
}
if (status.json.configDivergence.available && status.json.configDivergence.diverged) {
console.log(" ⚠️ config.json changed on disk after the running proxy loaded it; the proxy is still serving the older settings.");
console.log(" Action: restart the service to apply: 'ocx service restart'.");
}
console.log(` Catalog clamp: ${status.json.codexRuntime.catalogClamp.active ? "active" : "inactive"}`);
if (status.json.codexRuntime.catalogClamp.removedEfforts.length > 0) {
console.log(` Removed efforts: ${status.json.codexRuntime.catalogClamp.removedEfforts.join(", ")}`);
Expand Down
Loading
Loading