Skip to content

Commit 79deb8e

Browse files
lidge-junWibias
andcommitted
feat(debug): unified provider/usage debug CLI and GUI tab (cherry-pick PR #74)
Cherry-pick of 238283f + 6a17da2 + c1cc006 + 4679291 from feat/debug-provider-gui. All 4 review feedback items addressed by contributor: - tsc blocker: stale re-exports removed - Log polling: monotonic seq cursor replaces Date.now() - HTTP tests: /api/debug endpoints covered (auth, CORS, validation, redaction) - Usage logs: both streams now route through running proxy API Co-authored-by: Wibias <37517432+Wibias@users.noreply.github.com>
1 parent 070839a commit 79deb8e

19 files changed

Lines changed: 1128 additions & 33 deletions

gui/src/App.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,20 @@ import Providers from "./pages/Providers";
44
import Models from "./pages/Models";
55
import Subagents from "./pages/Subagents";
66
import Logs from "./pages/Logs";
7+
import Debug from "./pages/Debug";
78
import Usage from "./pages/Usage";
89
import CodexAuth from "./pages/CodexAuth";
910
import ApiKeys from "./pages/ApiKeys";
10-
import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconKey, IconGithub, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower } from "./icons";
11+
import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconTerminal, IconActivity, IconKey, IconGithub, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower } from "./icons";
1112
import { useI18n, useT, LOCALES, type TKey } from "./i18n";
1213
import { installApiAuthFetch } from "./api";
1314

1415
installApiAuthFetch();
1516

16-
type Page = "dashboard" | "providers" | "models" | "subagents" | "logs" | "usage" | "codex-auth" | "api";
17+
type Page = "dashboard" | "providers" | "models" | "subagents" | "logs" | "debug" | "usage" | "codex-auth" | "api";
1718
type Theme = "light" | "dark" | "system";
1819

19-
const VALID_PAGES = new Set<Page>(["dashboard", "providers", "models", "subagents", "logs", "usage", "codex-auth", "api"]);
20+
const VALID_PAGES = new Set<Page>(["dashboard", "providers", "models", "subagents", "logs", "debug", "usage", "codex-auth", "api"]);
2021

2122
function readPageFromHash(): Page {
2223
const raw = location.hash.replace(/^#\/?/, "");
@@ -32,6 +33,7 @@ const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [
3233
{ id: "models", tkey: "nav.models", Icon: IconBoxes },
3334
{ id: "subagents", tkey: "nav.subagents", Icon: IconBot },
3435
{ id: "logs", tkey: "nav.logs", Icon: IconList },
36+
{ id: "debug", tkey: "nav.debug", Icon: IconTerminal },
3537
{ id: "usage", tkey: "nav.usage", Icon: IconActivity },
3638
{ id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey },
3739
{ id: "api", tkey: "nav.api", Icon: IconGlobe },
@@ -147,6 +149,7 @@ export default function App() {
147149
{page === "models" && <Models apiBase={API_BASE} />}
148150
{page === "subagents" && <Subagents apiBase={API_BASE} />}
149151
{page === "logs" && <Logs apiBase={API_BASE} />}
152+
{page === "debug" && <Debug apiBase={API_BASE} />}
150153
{page === "usage" && <Usage apiBase={API_BASE} />}
151154
{page === "codex-auth" && <CodexAuth apiBase={API_BASE} />}
152155
{page === "api" && <ApiKeys apiBase={API_BASE} />}

gui/src/i18n/en.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const en = {
77
"nav.models": "Models",
88
"nav.subagents": "Subagents",
99
"nav.logs": "Logs",
10+
"nav.debug": "Debug",
1011
"nav.usage": "Usage",
1112
"common.github": "GitHub",
1213
"common.save": "Save",
@@ -232,6 +233,22 @@ export const en = {
232233
"logs.detailTitle": "Request details",
233234
"logs.detailRaw": "Raw log entry",
234235

236+
// debug page
237+
"debug.title": "Debug",
238+
"debug.subtitle": "Opt-in provider transport and usage-extraction diagnostics. Request errors and 502s stay on Logs.",
239+
"debug.debug": "Provider debug",
240+
"debug.usage": "Usage extraction",
241+
"debug.reset": "Clear runtime overrides",
242+
"debug.refresh": "Refresh",
243+
"debug.follow": "Follow",
244+
"debug.streamProvider": "Provider",
245+
"debug.streamUsage": "Usage",
246+
"debug.loading": "Loading debug settings…",
247+
"debug.emptyTitle": "Debug logging is off",
248+
"debug.empty": "Turn on Provider debug or Usage extraction in the card above. Lines appear here after you send a request through the proxy.",
249+
"debug.noLinesTitle": "Waiting for lines",
250+
"debug.noLines": "Debug is on but nothing has been captured yet. Send a chat/request through Codex, or run ocx debug provider logs -f in a terminal.",
251+
235252
// usage page
236253
"usage.title": "Usage",
237254
"usage.subtitle": "Local token accounting from your proxy. Missing usage is never shown as zero.",

gui/src/i18n/ko.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const ko: Record<TKey, string> = {
77
"nav.models": "모델",
88
"nav.subagents": "서브에이전트",
99
"nav.logs": "로그",
10+
"nav.debug": "디버그",
1011
"nav.usage": "사용량",
1112
"common.github": "GitHub",
1213
"common.save": "저장",
@@ -232,6 +233,21 @@ export const ko: Record<TKey, string> = {
232233
"logs.detailTitle": "요청 상세",
233234
"logs.detailRaw": "원본 로그",
234235

236+
"debug.title": "디버그",
237+
"debug.subtitle": "선택적 provider transport 및 usage 추출 진단. 요청 오류와 502는 Logs에 표시됩니다.",
238+
"debug.debug": "Provider debug",
239+
"debug.usage": "Usage 추출",
240+
"debug.reset": "런타임 재정의 해제",
241+
"debug.refresh": "새로고침",
242+
"debug.follow": "Follow",
243+
"debug.streamProvider": "Provider",
244+
"debug.streamUsage": "Usage",
245+
"debug.loading": "디버그 설정 로딩 중…",
246+
"debug.emptyTitle": "디버그 로깅 꺼짐",
247+
"debug.empty": "위 카드에서 Provider debug 또는 Usage extraction을 켜세요. 프록시로 요청을 보낸 뒤 라인이 표시됩니다.",
248+
"debug.noLinesTitle": "라인 대기 중",
249+
"debug.noLines": "디버그는 켜져 있지만 아직 캡처된 라인이 없습니다. Codex로 요청을 보내거나 ocx debug provider logs -f를 실행하세요.",
250+
235251
// usage page
236252
"usage.title": "사용량",
237253
"usage.subtitle": "프록시의 로컬 토큰 집계입니다. 누락된 사용량은 0으로 표시하지 않습니다.",

gui/src/i18n/zh.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const zh: Record<TKey, string> = {
77
"nav.models": "模型",
88
"nav.subagents": "子代理",
99
"nav.logs": "日志",
10+
"nav.debug": "调试",
1011
"nav.usage": "用量",
1112
"common.github": "GitHub",
1213
"common.save": "保存",
@@ -232,6 +233,21 @@ export const zh: Record<TKey, string> = {
232233
"logs.detailTitle": "请求详情",
233234
"logs.detailRaw": "原始日志",
234235

236+
"debug.title": "调试",
237+
"debug.subtitle": "可选的 provider transport 与 usage 提取诊断。请求错误和 502 在 Logs 页面。",
238+
"debug.debug": "Provider debug",
239+
"debug.usage": "Usage 提取",
240+
"debug.reset": "清除运行时覆盖",
241+
"debug.refresh": "刷新",
242+
"debug.follow": "跟随滚动",
243+
"debug.streamProvider": "Provider",
244+
"debug.streamUsage": "Usage",
245+
"debug.loading": "正在加载调试设置…",
246+
"debug.emptyTitle": "调试日志已关闭",
247+
"debug.empty": "请在上方卡片中开启 Provider debug 或 Usage extraction。通过代理发送请求后,诊断行会显示在这里。",
248+
"debug.noLinesTitle": "等待诊断行",
249+
"debug.noLines": "调试已开启但尚未捕获任何行。请通过 Codex 发送请求,或运行 ocx debug provider logs -f。",
250+
235251
// usage page
236252
"usage.title": "用量",
237253
"usage.subtitle": "代理本地的 Token 用量统计。缺失的用量不会显示为零。",

gui/src/icons.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const IconServer = (p: P) => (<svg {...S(p)}><rect x="3" y="4" width="18"
1212
export const IconBoxes = (p: P) => (<svg {...S(p)}><path d="M12 2 4 6v6l8 4 8-4V6l-8-4Z"/><path d="m4 6 8 4 8-4M12 10v8"/></svg>);
1313
export const IconBot = (p: P) => (<svg {...S(p)}><rect x="4" y="8" width="16" height="11" rx="3"/><path d="M12 8V4M8 2h8"/><circle cx="9" cy="13" r="1"/><circle cx="15" cy="13" r="1"/></svg>);
1414
export const IconList = (p: P) => (<svg {...S(p)}><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>);
15+
export const IconTerminal = (p: P) => (<svg {...S(p)}><path d="m4 17 6-5-6-5"/><path d="M12 19h8"/></svg>);
1516
export const IconActivity = (p: P) => (<svg {...S(p)}><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>);
1617

1718
export const IconCheck = (p: P) => (<svg {...S(p)}><path d="m20 6-11 11-5-5"/></svg>);

gui/src/pages/Debug.tsx

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { useCallback, useEffect, useRef, useState } from "react";
2+
import { useI18n } from "../i18n";
3+
import { IconRefresh } from "../icons";
4+
import { Switch } from "../ui";
5+
6+
interface DebugSettings {
7+
enabled: boolean;
8+
usage: boolean;
9+
runtimeOverride: Partial<Record<"debug" | "usage", boolean>>;
10+
env: Record<"debug" | "usage", boolean>;
11+
}
12+
13+
interface DebugLogEntry {
14+
seq: number;
15+
at: number;
16+
line: string;
17+
}
18+
19+
type LogStream = "provider" | "usage";
20+
21+
export default function Debug({ apiBase }: { apiBase: string }) {
22+
const { t } = useI18n();
23+
const [debug, setDebug] = useState<DebugSettings | null>(null);
24+
const [debugBusy, setDebugBusy] = useState(false);
25+
const [stream, setStream] = useState<LogStream>("provider");
26+
const [lines, setLines] = useState<string[]>([]);
27+
const [follow, setFollow] = useState(true);
28+
const [refreshing, setRefreshing] = useState(false);
29+
const afterRef = useRef(0);
30+
const logRef = useRef<HTMLPreElement | null>(null);
31+
32+
useEffect(() => {
33+
const fetchDebug = async () => {
34+
try {
35+
const res = await fetch(`${apiBase}/api/debug`);
36+
if (res.ok) setDebug(await res.json());
37+
} catch { /* ignore */ }
38+
};
39+
void fetchDebug();
40+
const interval = setInterval(() => void fetchDebug(), 2000);
41+
return () => clearInterval(interval);
42+
}, [apiBase]);
43+
44+
useEffect(() => {
45+
if (!debug) return;
46+
if (debug.enabled && stream === "usage" && !debug.usage) setStream("provider");
47+
if (debug.usage && stream === "provider" && !debug.enabled) setStream("usage");
48+
}, [debug, stream]);
49+
50+
const streamEnabled = stream === "provider" ? !!debug?.enabled : !!debug?.usage;
51+
const logsPath = stream === "provider" ? `${apiBase}/api/debug/logs` : `${apiBase}/api/debug/usage-logs`;
52+
53+
const fetchLogs = useCallback(async (initial: boolean) => {
54+
if (!streamEnabled) {
55+
setLines([]);
56+
afterRef.current = 0;
57+
return;
58+
}
59+
setRefreshing(true);
60+
try {
61+
const params = new URLSearchParams({ limit: "500" });
62+
if (!initial && afterRef.current > 0) params.set("after", String(afterRef.current));
63+
const res = await fetch(`${logsPath}?${params}`);
64+
if (!res.ok) return;
65+
const entries = await res.json() as DebugLogEntry[];
66+
if (entries.length === 0) return;
67+
setLines(prev => initial
68+
? entries.map(entry => entry.line)
69+
: [...prev, ...entries.map(entry => entry.line)].slice(-2000));
70+
afterRef.current = entries[entries.length - 1]!.seq;
71+
} catch { /* ignore */ } finally {
72+
setRefreshing(false);
73+
}
74+
}, [logsPath, streamEnabled]);
75+
76+
useEffect(() => {
77+
afterRef.current = 0;
78+
setLines([]);
79+
void fetchLogs(true);
80+
}, [stream, streamEnabled, fetchLogs]);
81+
82+
useEffect(() => {
83+
if (!follow || !streamEnabled) return;
84+
const interval = setInterval(() => void fetchLogs(false), 1000);
85+
return () => clearInterval(interval);
86+
}, [follow, streamEnabled, fetchLogs]);
87+
88+
useEffect(() => {
89+
if (!follow || !logRef.current) return;
90+
logRef.current.scrollTop = logRef.current.scrollHeight;
91+
}, [lines, follow]);
92+
93+
const setDebugFlag = async (flag: "debug" | "usage", enabled: boolean) => {
94+
setDebugBusy(true);
95+
try {
96+
const res = await fetch(`${apiBase}/api/debug`, {
97+
method: "PUT",
98+
headers: { "content-type": "application/json" },
99+
body: JSON.stringify({ [flag]: enabled }),
100+
});
101+
if (res.ok) setDebug(await res.json());
102+
} catch { /* ignore */ } finally {
103+
setDebugBusy(false);
104+
}
105+
};
106+
107+
const resetDebug = async () => {
108+
setDebugBusy(true);
109+
try {
110+
const res = await fetch(`${apiBase}/api/debug`, {
111+
method: "PUT",
112+
headers: { "content-type": "application/json" },
113+
body: JSON.stringify({ reset: true }),
114+
});
115+
if (res.ok) setDebug(await res.json());
116+
} catch { /* ignore */ } finally {
117+
setDebugBusy(false);
118+
}
119+
};
120+
121+
return (
122+
<>
123+
<div className="page-head">
124+
<h2>{t("debug.title")}</h2>
125+
<div style={{ display: "inline-flex", alignItems: "center", gap: 12 }}>
126+
<button
127+
type="button"
128+
className="btn btn-ghost btn-sm"
129+
disabled={refreshing || !streamEnabled}
130+
onClick={() => void fetchLogs(true)}
131+
>
132+
<IconRefresh /> {t("debug.refresh")}
133+
</button>
134+
<label className="muted" style={{ fontSize: 13, cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6 }}>
135+
<input type="checkbox" checked={follow} onChange={e => setFollow(e.target.checked)} />
136+
{t("debug.follow")}
137+
</label>
138+
</div>
139+
</div>
140+
<p className="page-sub">{t("debug.subtitle")}</p>
141+
142+
{!debug ? (
143+
<div className="empty">{t("debug.loading")}</div>
144+
) : (
145+
<div className="card" style={{ marginBottom: 16, padding: "12px 14px" }}>
146+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
147+
<div style={{ display: "flex", flexWrap: "wrap", gap: 16 }}>
148+
{(["debug", "usage"] as const).map(flag => {
149+
const checked = flag === "debug" ? debug.enabled : debug.usage;
150+
return (
151+
<div key={flag} style={{ display: "inline-flex", alignItems: "center", gap: 10, minWidth: 220 }}>
152+
<Switch
153+
on={checked}
154+
disabled={debugBusy}
155+
label={t(`debug.${flag}`)}
156+
onClick={() => void setDebugFlag(flag, !checked)}
157+
/>
158+
<span style={{ fontSize: 13 }}>{t(`debug.${flag}`)}</span>
159+
</div>
160+
);
161+
})}
162+
</div>
163+
<button type="button" className="btn btn-ghost btn-sm" disabled={debugBusy} onClick={() => void resetDebug()}>
164+
{t("debug.reset")}
165+
</button>
166+
</div>
167+
168+
{(debug.enabled || debug.usage) && (
169+
<div style={{ display: "inline-flex", gap: 6, marginTop: 12 }}>
170+
{debug.enabled && (
171+
<button
172+
type="button"
173+
className={`btn btn-sm${stream === "provider" ? " btn-primary" : " btn-ghost"}`}
174+
onClick={() => setStream("provider")}
175+
>
176+
{t("debug.streamProvider")}
177+
</button>
178+
)}
179+
{debug.usage && (
180+
<button
181+
type="button"
182+
className={`btn btn-sm${stream === "usage" ? " btn-primary" : " btn-ghost"}`}
183+
onClick={() => setStream("usage")}
184+
>
185+
{t("debug.streamUsage")}
186+
</button>
187+
)}
188+
</div>
189+
)}
190+
</div>
191+
)}
192+
193+
{debug && !streamEnabled ? (
194+
<div className="empty">
195+
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t("debug.emptyTitle")}</div>
196+
<div className="muted" style={{ fontSize: 13, maxWidth: 560 }}>{t("debug.empty")}</div>
197+
</div>
198+
) : debug && streamEnabled && lines.length === 0 ? (
199+
<div className="empty">
200+
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t("debug.noLinesTitle")}</div>
201+
<div className="muted" style={{ fontSize: 13, maxWidth: 560 }}>{t("debug.noLines")}</div>
202+
</div>
203+
) : debug && streamEnabled ? (
204+
<pre ref={logRef} className="log-detail-json" style={{ maxHeight: "calc(100vh - 280px)" }}>{lines.join("\n")}</pre>
205+
) : null}
206+
</>
207+
);
208+
}

0 commit comments

Comments
 (0)