Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f61502f
test: 添加简易测试 test:local-chat 只覆盖了各个模型的 openai 格式的 curl 请求
SsuJojo Apr 14, 2026
5c532c6
chore: bump version to 2.0.37 [skip ci]
github-actions[bot] Apr 14, 2026
ba189c3
test: 添加简易测试 test:local-chat 只覆盖了各个模型的 openai 格式的 curl 请求
SsuJojo Apr 14, 2026
865b231
chore: bump version to 2.0.37 [skip ci]
github-actions[bot] Apr 14, 2026
03e01ab
feat: add logs tab and backend
SsuJojo Apr 14, 2026
74e1aa6
Merge branch 'master' of https://github.com/SsuJojo/codex-proxy into …
SsuJojo Apr 14, 2026
7c25671
Merge remote-tracking branch 'upstream/master'
SsuJojo Apr 14, 2026
4ee1727
Merge branch 'master' into logs-tab
SsuJojo Apr 14, 2026
ae594aa
feat: add log tab
SsuJojo Apr 14, 2026
a34800f
fix
SsuJojo Apr 14, 2026
4792d8a
feat: add dashboard logs tab
SsuJojo Apr 14, 2026
e66dc08
refactor: bound request logs and add coverage
SsuJojo Apr 15, 2026
cec05c8
feat: 本地化 logs 的相关设置项
SsuJojo Apr 15, 2026
ba4efbf
feat: add dashboard logs tab
SsuJojo Apr 15, 2026
81a268c
fix: tighten logs pagination state handling
SsuJojo Apr 15, 2026
a5b6954
Merge master into pr/371.
SsuJojo Apr 15, 2026
b5e4bec
Merge branch 'master' of https://github.com/icebear0828/codex-proxy i…
SsuJojo Apr 16, 2026
26ce986
fix: correct logs pagination and honor body capture setting
SsuJojo Apr 17, 2026
e4dffc8
feat: add persisted LLM-only log capture mode
SsuJojo Apr 17, 2026
cf56c7e
feat: separate logs settings drawer and sync enabled state
SsuJojo Apr 17, 2026
753f01a
fix: sync log controls and preserve zero-latency display
SsuJojo Apr 17, 2026
d01b2c7
fix: restore build after logs merge conflicts
SsuJojo Apr 17, 2026
f5c3b51
Localize log pagination and tighten packaging tests
SsuJojo Apr 17, 2026
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Added

- Dashboard: new Logs tab to inspect ingress/egress requests, with enable/pause controls, filters, search, and details panel.
- 控制台新增日志页面:支持启用/暂停、方向筛选、搜索与详情查看,便于排查请求流向。
- `auth.tier_priority` 配置项:按 plan 类型排序账号选择优先级(如 `["plus", "pro", "team", "free"]`),高优先级 tier 的账号在有可用时始终优先选择;默认 `null`(不启用),与所有轮转策略兼容 (#348)

- `server.trust_proxy` config option (default `false`): when enabled, the real client IP is read from `X-Forwarded-For` / `X-Real-IP` headers instead of the raw socket address. Required for users who expose codex-proxy via tunnel software (frp, ngrok, etc.) so that dashboard auth works correctly — previously all tunnel traffic appeared as `127.0.0.1` and bypassed authentication even when `proxy_api_key` was set (#350)
Expand Down
5 changes: 5 additions & 0 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ server:
port: 8080
proxy_api_key: null
trust_proxy: false
logs:
enabled: false
capacity: 2000
capture_body: false
llm_only: true
session:
ttl_minutes: 60
cleanup_interval_minutes: 5
Expand Down
8 changes: 8 additions & 0 deletions shared/hooks/use-general-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export interface GeneralSettingsData {
refresh_concurrency: number;
auto_update: boolean;
auto_download: boolean;
logs_enabled: boolean;
logs_capacity: number;
logs_capture_body: boolean;
logs_llm_only: boolean;
}

interface GeneralSettingsSaveResponse extends GeneralSettingsData {
Expand Down Expand Up @@ -72,6 +76,10 @@ export function useGeneralSettings(apiKey: string | null) {
refresh_concurrency: result.refresh_concurrency,
auto_update: result.auto_update,
auto_download: result.auto_download,
logs_enabled: result.logs_enabled,
logs_capacity: result.logs_capacity,
logs_capture_body: result.logs_capture_body,
logs_llm_only: result.logs_llm_only,
});
setRestartRequired(result.restart_required);
setSaved(true);
Expand Down
45 changes: 45 additions & 0 deletions shared/hooks/use-logs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect, vi } from "vitest";

vi.mock("preact/hooks", () => ({
useState: vi.fn(),
useEffect: vi.fn(),
useCallback: (fn: unknown) => fn,
useRef: vi.fn(),
}));

import { normalizeLogsQueryState } from "./use-logs.js";

describe("normalizeLogsQueryState", () => {
it("resets page and clears selection when filters change", () => {
const next = normalizeLogsQueryState(
{ direction: "all", search: "", page: 3, selected: { id: "1" } },
{ direction: "egress" },
);

expect(next.direction).toBe("egress");
expect(next.page).toBe(0);
expect(next.selected).toBeNull();
});

it("keeps page for pagination changes but clears selection", () => {
const next = normalizeLogsQueryState(
{ direction: "all", search: "abc", page: 1, selected: { id: "1" } },
{ page: 2 },
);

expect(next.search).toBe("abc");
expect(next.page).toBe(2);
expect(next.selected).toBeNull();
});

it("preserves selection when query state is unchanged", () => {
const selected = { id: "1" };
const next = normalizeLogsQueryState(
{ direction: "all", search: "abc", page: 1, selected },
{},
);

expect(next.page).toBe(1);
expect(next.selected).toBe(selected);
});
});
179 changes: 179 additions & 0 deletions shared/hooks/use-logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { useState, useEffect, useCallback, useRef } from "preact/hooks";

export type LogFilterDirection = "ingress" | "egress" | "all";

export function normalizeLogsQueryState<T>(
prev: { direction: LogFilterDirection; search: string; page: number; selected: T | null },
next: { direction?: LogFilterDirection; search?: string; page?: number },
): { direction: LogFilterDirection; search: string; page: number; selected: T | null } {
const direction = next.direction ?? prev.direction;
const search = next.search ?? prev.search;
const page = next.page ?? prev.page;
const queryChanged = direction !== prev.direction || search !== prev.search;
const pageChanged = page !== prev.page;
return {
direction,
search,
page: queryChanged ? 0 : page,
selected: queryChanged || pageChanged ? null : prev.selected,
};
}


export interface LogRecord {
id: string;
requestId: string;
direction: "ingress" | "egress";
ts: string;
method: string;
path: string;
model?: string | null;
provider?: string | null;
status?: number | null;
latencyMs?: number | null;
stream?: boolean | null;
error?: string | null;
request?: unknown;
response?: unknown;
}

export interface LogState {
enabled: boolean;
paused: boolean;
dropped: number;
size: number;
capacity: number;
}

export function useLogs(refreshIntervalMs = 1500) {
const [direction, setDirectionState] = useState<LogFilterDirection>("all");
const [search, setSearchState] = useState("");
const [records, setRecords] = useState<LogRecord[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [state, setState] = useState<LogState | null>(null);
const [selected, setSelected] = useState<LogRecord | null>(null);
const [page, setPageState] = useState(0);
const pageSize = 50;
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);

const load = useCallback(async (nextPage: number) => {
try {
const params = new URLSearchParams({
direction,
search: search.trim(),
limit: String(pageSize),
offset: String(nextPage * pageSize),
});
const resp = await fetch(`/admin/logs?${params.toString()}`);
if (resp.ok) {
const body = await resp.json();
setRecords(body.records);
setTotal(body.total);
}
} catch { /* ignore */ }
setLoading(false);
}, [direction, search, pageSize]);

const setDirection = useCallback((nextDirection: LogFilterDirection) => {
setDirectionState((prevDirection) => {
const next = normalizeLogsQueryState({ direction: prevDirection, search, page, selected }, { direction: nextDirection });
setPageState(next.page);
setSelected(next.selected);
return next.direction;
});
}, [search, page, selected]);

const setSearch = useCallback((nextSearch: string) => {
const next = normalizeLogsQueryState({ direction, search, page, selected }, { search: nextSearch });
setSearchState(next.search);
setPageState(next.page);
setSelected(next.selected);
}, [direction, search, page, selected]);

const setPage = useCallback((updater: number | ((prev: number) => number)) => {
setPageState((prevPage) => {
const nextPage = typeof updater === "function" ? updater(prevPage) : updater;
const next = normalizeLogsQueryState({ direction, search, page: prevPage, selected }, { page: nextPage });
setSelected(next.selected);
return next.page;
});
}, [direction, search, selected]);

const loadState = useCallback(async () => {
try {
const resp = await fetch("/admin/logs/state");
if (resp.ok) setState(await resp.json());
} catch { /* ignore */ }
}, []);

const clearTimer = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);

useEffect(() => {
setLoading(true);
load(page);
loadState();
clearTimer();

const tick = () => {
if (!document.hidden) {
load(page);
loadState();
}
};

timerRef.current = setInterval(tick, refreshIntervalMs);
const onVisibility = () => {
if (!document.hidden) tick();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
clearTimer();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [load, loadState, page, refreshIntervalMs, clearTimer]);

const setLogState = useCallback(async (patch: Partial<Pick<LogState, "enabled" | "paused">>) => {
const resp = await fetch("/admin/logs/state", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (resp.ok) setState(await resp.json());
}, []);

const selectLog = useCallback(async (id: string) => {
try {
const resp = await fetch(`/admin/logs/${id}`);
if (resp.ok) setSelected(await resp.json());
} catch { /* ignore */ }
}, []);

const nextPage = useCallback(() => setPage((p) => p + 1), []);
const prevPage = useCallback(() => setPage((p) => Math.max(0, p - 1)), []);

return {
direction,
setDirection,
search,
setSearch,
records,
total,
loading,
state,
setLogState,
selected,
selectLog,
page,
pageSize,
nextPage,
prevPage,
hasNext: (page + 1) * pageSize < total,
hasPrev: page > 0,
};
}
12 changes: 12 additions & 0 deletions shared/i18n/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import { interpolateTranslation } from "./context";

describe("interpolateTranslation", () => {
it("replaces template variables", () => {
expect(interpolateTranslation("共 {count} 条", { count: 2 })).toBe("共 2 条");
});

it("keeps unknown variables unchanged", () => {
expect(interpolateTranslation("{count} / {total}", { count: 2 })).toBe("2 / {total}");
});
});
12 changes: 9 additions & 3 deletions shared/i18n/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ import type { ComponentChildren } from "preact";
interface I18nContextValue {
lang: LangCode;
toggleLang: () => void;
t: (key: TranslationKey) => string;
t: (key: TranslationKey, vars?: Record<string, string | number>) => string;
}

const I18nContext = createContext<I18nContextValue>(null!);

export function interpolateTranslation(template: string, vars?: Record<string, string | number>): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (_, name: string) => String(vars[name] ?? `{${name}}`));
}

function getInitialLang(): LangCode {
try {
const saved = localStorage.getItem("codex-proxy-lang");
Expand All @@ -31,8 +36,9 @@ export function I18nProvider({ children }: { children: ComponentChildren }) {
}, []);

const t = useCallback(
(key: TranslationKey): string => {
return translations[lang][key] ?? translations.en[key] ?? key;
(key: TranslationKey, vars?: Record<string, string | number>): string => {
const template = translations[lang][key] ?? translations.en[key] ?? key;
return interpolateTranslation(template, vars);
},
[lang]
);
Expand Down
66 changes: 66 additions & 0 deletions shared/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,37 @@ export const translations = {
cancel: "Cancel",
apiKeys: "API Keys",
usageStats: "Usage Stats",
logs: "Logs",
logsEnabled: "Enabled",
logsEnabledHint: "Enable request logging and history.",
logsDisabled: "Disabled",
logsPaused: "Paused",
logsRunning: "Running",
logsCapacity: "Log Capacity",
logsCapacityHint: "Maximum number of logs to keep before older entries are dropped.",
logsCaptureBody: "Capture Request/Response Bodies",
logsCaptureBodyHint: "Store request and response bodies in logs.",
logsLlmOnly: "Only record LLM logs",
logsLlmOnlyHint: "When enabled, only keep LLM-related routes and forwarded requests in logs.",
logsModeLlmOnlyToggle: "Only record LLM logs (click to toggle)",
logsModeAllToggle: "Record all request logs (click to toggle)",
"logsFilter.all": "All",
"logsFilter.ingress": "Ingress",
"logsFilter.egress": "Egress",
logsSearch: "Search logs...",
logsCount: "{count} logs",
logsTime: "Time",
logsDirection: "Direction",
logsPath: "Path",
logsStatus: "Status",
logsLatency: "Latency",
logsLoading: "Loading logs...",
logsEmpty: "No logs",
logsDetails: "Details",
logsSelectHint: "Select a log to view details",
logsPrev: "Prev",
logsNext: "Next",
logsPageSummary: "{total} total · {range}",
totalInputTokens: "Input Tokens",
totalOutputTokens: "Output Tokens",
totalRequestCount: "Total Requests",
Expand Down Expand Up @@ -260,6 +291,8 @@ export const translations = {
generalSettingsAutoUpdateHint: "Periodically check for new versions.",
generalSettingsAutoDownload: "Auto Download Updates",
generalSettingsAutoDownloadHint: "Download updates silently without asking. If off, a dialog will prompt before downloading.",
logsSettings: "Log Settings",
logsEnable: "Enable Logs",
proxyExport: "Export",
proxyImport: "Import",
proxyImportTitle: "Import Proxies (YAML)",
Expand Down Expand Up @@ -524,6 +557,39 @@ export const translations = {
cancel: "取消",
apiKeys: "API Keys",
usageStats: "用量统计",
logs: "日志",
logsSettings: "日志设置",
logsEnable: "启用日志",
logsEnabled: "已启用",
logsEnabledHint: "开启请求日志与历史记录。",
logsDisabled: "已禁用",
logsPaused: "已暂停",
logsRunning: "运行中",
logsCapacity: "日志容量",
logsCapacityHint: "最多保留多少条日志,超出后会删除较旧的记录。",
logsCaptureBody: "记录请求/响应正文",
logsCaptureBodyHint: "在日志中保存请求和响应正文。",
logsLlmOnly: "仅记录 LLM 日志",
logsLlmOnlyHint: "开启后,只保留 LLM 相关路由和实际转发请求的日志。",
logsModeLlmOnlyToggle: "仅记录LLM日志(点击切换)",
logsModeAllToggle: "记录全部请求日志(点击切换)",
"logsFilter.all": "全部",
"logsFilter.ingress": "入口",
"logsFilter.egress": "出口",
logsSearch: "搜索日志...",
logsCount: "共 {count} 条",
logsTime: "时间",
logsDirection: "方向",
logsPath: "路径",
logsStatus: "状态",
logsLatency: "耗时",
logsLoading: "加载日志中...",
logsEmpty: "暂无日志",
logsDetails: "详情",
logsSelectHint: "选择一条日志查看详情",
logsPrev: "上一页",
logsNext: "下一页",
logsPageSummary: "共 {total} 条 · {range}",
totalInputTokens: "输入 Token",
totalOutputTokens: "输出 Token",
totalRequestCount: "总请求数",
Expand Down
Loading
Loading