From f61502fa4c21e2a7cfe3ada1c5c6af6984872f0d Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Tue, 14 Apr 2026 17:22:57 +0800 Subject: [PATCH 01/17] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=E7=AE=80?= =?UTF-8?q?=E6=98=93=E6=B5=8B=E8=AF=95=20test:local-chat=20=E5=8F=AA?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E4=BA=86=E5=90=84=E4=B8=AA=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9A=84=20openai=20=E6=A0=BC=E5=BC=8F=E7=9A=84=20curl=20?= =?UTF-8?q?=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + vitest.config.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/package.json b/package.json index fc4af430..08453e8d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "scripts": { "test": "vitest run", "test:watch": "vitest", + "test:local-chat": "vitest run tests/local-chat/chat-completions.test.ts", "dev": "tsx watch src/index.ts", "dev:web": "cd web && npx vite", "build:web": "cd web && npx vite build", diff --git a/vitest.config.ts b/vitest.config.ts index c54886cc..6715db33 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ "shared/**/*.{test,spec}.ts", "tests/unit/**/*.{test,spec}.ts", "tests/integration/**/*.{test,spec}.ts", + "tests/local-chat/**/*.{test,spec}.ts", "packages/electron/__tests__/**/*.{test,spec}.ts", ], }, From 5c532c681bd6b066359e244eb6346dd7702e5f75 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Apr 2026 09:23:20 +0000 Subject: [PATCH 02/17] chore: bump version to 2.0.37 [skip ci] --- package.json | 2 +- packages/electron/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 08453e8d..b0cfa855 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-proxy", - "version": "2.0.60", + "version": "2.0.37", "description": "Reverse proxy that exposes Codex Desktop Responses API as OpenAI-compatible /v1/chat/completions", "contributors": [ { diff --git a/packages/electron/package.json b/packages/electron/package.json index f9da0df1..5ee28f62 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@codex-proxy/electron", - "version": "2.0.60", + "version": "2.0.37", "description": "Codex Proxy desktop app (Electron shell)", "private": true, "main": "dist-electron/main.cjs", From ba189c3cf9cf63ada57d718a41b947e76ee9a0f8 Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Tue, 14 Apr 2026 17:22:57 +0800 Subject: [PATCH 03/17] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=E7=AE=80?= =?UTF-8?q?=E6=98=93=E6=B5=8B=E8=AF=95=20test:local-chat=20=E5=8F=AA?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E4=BA=86=E5=90=84=E4=B8=AA=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9A=84=20openai=20=E6=A0=BC=E5=BC=8F=E7=9A=84=20curl=20?= =?UTF-8?q?=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + vitest.config.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/package.json b/package.json index 73aa75e7..1880b6c1 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "scripts": { "test": "vitest run", "test:watch": "vitest", + "test:local-chat": "vitest run tests/local-chat/chat-completions.test.ts", "dev": "tsx watch src/index.ts", "dev:web": "cd web && npx vite", "build:web": "cd web && npx vite build", diff --git a/vitest.config.ts b/vitest.config.ts index c54886cc..6715db33 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ "shared/**/*.{test,spec}.ts", "tests/unit/**/*.{test,spec}.ts", "tests/integration/**/*.{test,spec}.ts", + "tests/local-chat/**/*.{test,spec}.ts", "packages/electron/__tests__/**/*.{test,spec}.ts", ], }, From 865b2312a38a62f19b0d2347b3f6a0d8d6f998e6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Apr 2026 09:23:20 +0000 Subject: [PATCH 04/17] chore: bump version to 2.0.37 [skip ci] --- package.json | 2 +- packages/electron/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1880b6c1..9e62cc56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-proxy", - "version": "2.0.61", + "version": "2.0.61" "description": "Reverse proxy that exposes Codex Desktop Responses API as OpenAI-compatible /v1/chat/completions", "contributors": [ { diff --git a/packages/electron/package.json b/packages/electron/package.json index 9ffbd007..6b887ba2 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@codex-proxy/electron", - "version": "2.0.61", + "version": "2.0.61" "description": "Codex Proxy desktop app (Electron shell)", "private": true, "main": "dist-electron/main.cjs", From 03e01ab0f9c500c39bc44320050afb3abb70e95f Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Tue, 14 Apr 2026 17:53:39 +0800 Subject: [PATCH 05/17] feat: add logs tab and backend Add in-memory logging with admin endpoints and dashboard logs tab. --- shared/hooks/use-logs.ts | 104 ++++++++++++++++++++++++++++ src/logs/redact.ts | 44 ++++++++++++ src/logs/store.ts | 135 +++++++++++++++++++++++++++++++++++++ src/routes/admin/logs.ts | 47 +++++++++++++ web/src/pages/LogsPage.tsx | 107 +++++++++++++++++++++++++++++ 5 files changed, 437 insertions(+) create mode 100644 shared/hooks/use-logs.ts create mode 100644 src/logs/redact.ts create mode 100644 src/logs/store.ts create mode 100644 src/routes/admin/logs.ts create mode 100644 web/src/pages/LogsPage.tsx diff --git a/shared/hooks/use-logs.ts b/shared/hooks/use-logs.ts new file mode 100644 index 00000000..169968bb --- /dev/null +++ b/shared/hooks/use-logs.ts @@ -0,0 +1,104 @@ +import { useState, useEffect, useCallback } from "preact/hooks"; + +export type LogDirection = "ingress" | "egress" | "all"; + +export interface LogRecord { + id: string; + requestId: string; + direction: LogDirection; + 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, setDirection] = useState("all"); + const [search, setSearch] = useState(""); + const [records, setRecords] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [state, setState] = useState(null); + const [selected, setSelected] = useState(null); + + const load = useCallback(async () => { + try { + const params = new URLSearchParams({ + direction, + search: search.trim(), + limit: "50", + offset: "0", + }); + 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]); + + const loadState = useCallback(async () => { + try { + const resp = await fetch("/admin/logs/state"); + if (resp.ok) setState(await resp.json()); + } catch { /* ignore */ } + }, []); + + useEffect(() => { + setLoading(true); + load(); + loadState(); + const id = setInterval(() => { + load(); + loadState(); + }, refreshIntervalMs); + return () => clearInterval(id); + }, [load, loadState, refreshIntervalMs]); + + const setLogState = useCallback(async (patch: Partial>) => { + 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 */ } + }, []); + + return { + direction, + setDirection, + search, + setSearch, + records, + total, + loading, + state, + setLogState, + selected, + selectLog, + }; +} diff --git a/src/logs/redact.ts b/src/logs/redact.ts new file mode 100644 index 00000000..d02d8ef1 --- /dev/null +++ b/src/logs/redact.ts @@ -0,0 +1,44 @@ +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +const SECRET_KEY_RE = /(authorization|x-api-key|api_key|apikey|token|refresh_token|access_token|cookie|set-cookie|session|secret)/i; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function redactString(value: string): string { + if (!value) return value; + if (value.length <= 8) return "***"; + return `${value.slice(0, 3)}***${value.slice(-2)}`; +} + +export function redactJson(value: unknown, depth = 0): JsonValue { + if (depth > 6) return "***"; + if (value === null || value === undefined) return null; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) return value.map((v) => redactJson(v, depth + 1)); + if (isRecord(value)) { + const out: Record = {}; + for (const [key, v] of Object.entries(value)) { + if (SECRET_KEY_RE.test(key)) { + if (typeof v === "string") out[key] = redactString(v); + else out[key] = "***"; + } else if (key.toLowerCase() === "headers" && isRecord(v)) { + const headersOut: Record = {}; + for (const [hKey, hVal] of Object.entries(v)) { + if (SECRET_KEY_RE.test(hKey)) { + headersOut[hKey] = typeof hVal === "string" ? redactString(hVal) : "***"; + } else { + headersOut[hKey] = redactJson(hVal, depth + 1); + } + } + out[key] = headersOut; + } else { + out[key] = redactJson(v, depth + 1); + } + } + return out; + } + return String(value); +} diff --git a/src/logs/store.ts b/src/logs/store.ts new file mode 100644 index 00000000..1fb0999f --- /dev/null +++ b/src/logs/store.ts @@ -0,0 +1,135 @@ +import { redactJson } from "./redact.js"; + +export type LogDirection = "ingress" | "egress"; + +export interface LogRecord { + id: string; + requestId: string; + direction: LogDirection; + ts: string; + method: string; + path: string; + model?: string | null; + provider?: string | null; + status?: number | null; + latencyMs?: number | null; + stream?: boolean | null; + sizeBytes?: number | null; + error?: string | null; + tags?: string[]; + request?: unknown; + response?: unknown; + meta?: Record; +} + +export interface LogState { + enabled: boolean; + paused: boolean; + dropped: number; + size: number; + capacity: number; +} + +export interface LogQuery { + direction?: LogDirection | "all"; + search?: string | null; + limit?: number; + offset?: number; +} + +const DEFAULT_CAPACITY = 2000; + +export class LogStore { + private records: LogRecord[] = []; + private readonly capacity: number; + private enabled = true; + private paused = false; + private dropped = 0; + private queue: LogRecord[] = []; + private flushScheduled = false; + + constructor(capacity = DEFAULT_CAPACITY) { + this.capacity = capacity; + } + + getState(): LogState { + return { + enabled: this.enabled, + paused: this.paused, + dropped: this.dropped, + size: this.records.length, + capacity: this.capacity, + }; + } + + setState(next: Partial>): LogState { + if (typeof next.enabled === "boolean") this.enabled = next.enabled; + if (typeof next.paused === "boolean") this.paused = next.paused; + return this.getState(); + } + + clear(): void { + this.records = []; + this.dropped = 0; + } + + enqueue(record: LogRecord): void { + if (!this.enabled || this.paused) return; + this.queue.push(record); + if (!this.flushScheduled) { + this.flushScheduled = true; + queueMicrotask(() => this.flush()); + } + } + + list(query: LogQuery): { records: LogRecord[]; total: number; offset: number; limit: number } { + const direction = query.direction ?? "all"; + const search = (query.search ?? "").trim().toLowerCase(); + let results = this.records; + + if (direction !== "all") { + results = results.filter((r) => r.direction === direction); + } + + if (search) { + results = results.filter((r) => { + const hay = `${r.method} ${r.path} ${r.model ?? ""} ${r.provider ?? ""} ${r.status ?? ""}`.toLowerCase(); + return hay.includes(search); + }); + } + + const total = results.length; + const limit = Math.min(Math.max(1, query.limit ?? 50), 200); + const offset = Math.max(0, query.offset ?? 0); + const sliced = results.slice(offset, offset + limit); + + return { records: sliced, total, offset, limit }; + } + + get(id: string): LogRecord | null { + return this.records.find((r) => r.id === id) ?? null; + } + + private flush(): void { + this.flushScheduled = false; + if (!this.queue.length) return; + + const batch = this.queue.splice(0, this.queue.length); + for (const record of batch) { + const redacted: LogRecord = { + ...record, + request: record.request !== undefined ? redactJson(record.request) : undefined, + response: record.response !== undefined ? redactJson(record.response) : undefined, + }; + this.records.push(redacted); + } + + if (this.records.length > this.capacity) { + const over = this.records.length - this.capacity; + this.records.splice(0, over); + this.dropped += over; + } + } +} + +export const logStore = new LogStore(); diff --git a/src/routes/admin/logs.ts b/src/routes/admin/logs.ts new file mode 100644 index 00000000..c142f943 --- /dev/null +++ b/src/routes/admin/logs.ts @@ -0,0 +1,47 @@ +import { Hono } from "hono"; +import { logStore, type LogDirection } from "../../logs/store.js"; + +function parseDirection(raw: string | null): LogDirection | "all" { + if (raw === "ingress" || raw === "egress" || raw === "all") return raw; + return "all"; +} + +export function createLogRoutes(): Hono { + const app = new Hono(); + + app.get("/admin/logs", (c) => { + const direction = parseDirection(c.req.query("direction")); + const search = c.req.query("search"); + const limit = parseInt(c.req.query("limit") ?? "50", 10); + const offset = parseInt(c.req.query("offset") ?? "0", 10); + const data = logStore.list({ direction, search, limit, offset }); + return c.json(data); + }); + + app.get("/admin/logs/state", (c) => { + return c.json(logStore.getState()); + }); + + app.post("/admin/logs/state", async (c) => { + const body = await c.req.json().catch(() => ({} as Record)); + const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined; + const paused = typeof body.paused === "boolean" ? body.paused : undefined; + return c.json(logStore.setState({ enabled, paused })); + }); + + app.post("/admin/logs/clear", (c) => { + logStore.clear(); + return c.json({ ok: true }); + }); + + app.get("/admin/logs/:id", (c) => { + const rec = logStore.get(c.req.param("id")); + if (!rec) { + c.status(404); + return c.json({ error: "not_found" }); + } + return c.json(rec); + }); + + return app; +} diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx new file mode 100644 index 00000000..25a386db --- /dev/null +++ b/web/src/pages/LogsPage.tsx @@ -0,0 +1,107 @@ +import { useMemo } from "preact/hooks"; +import { useT } from "../../shared/i18n/context"; +import { useLogs } from "../../shared/hooks/use-logs"; + +export function LogsPage({ embedded = false }: { embedded?: boolean }) { + const t = useT(); + const logs = useLogs(); + + const list = useMemo(() => { + return logs.records.map((r) => ({ + ...r, + time: new Date(r.ts).toLocaleTimeString(), + })); + }, [logs.records]); + + return ( +
+
+ + + +
+ {(["all", "ingress", "egress"] as const).map((dir) => ( + + ))} +
+ + logs.setSearch((e.target as HTMLInputElement).value)} + placeholder={t("logsSearch")} + /> + +
+ {t("logsCount", { count: logs.total })} +
+
+ +
+
+
+
+
{t("logsTime")}
+
{t("logsDirection")}
+
{t("logsPath")}
+
{t("logsStatus")}
+
{t("logsLatency")}
+
+ {logs.loading && ( +
{t("logsLoading")}
+ )} + {!logs.loading && list.length === 0 && ( +
{t("logsEmpty")}
+ )} +
+ {list.map((row) => ( + + ))} +
+
+
+ +
+
+
+ {t("logsDetails")} +
+
+ {logs.selected ? JSON.stringify(logs.selected, null, 2) : t("logsSelectHint")} +
+
+
+
+
+ ); +} From ae594aadd72f2cc08817d24d90b541efde4d40bc Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Tue, 14 Apr 2026 18:20:21 +0800 Subject: [PATCH 06/17] feat: add log tab --- package.json | 4 + packages/electron/package.json | 4 + shared/i18n/translations.ts | 38 +++++ src/routes/chat.ts | 20 +++ src/routes/messages.ts | 20 +++ src/routes/responses.ts | 37 +++++ src/routes/shared/proxy-handler.ts | 233 ++++++++++++++++++++++------- src/routes/web.ts | 2 + web/src/App.tsx | 6 + 9 files changed, 310 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index 1880b6c1..85279032 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,10 @@ { "name": "codex-proxy", +<<<<<<< HEAD "version": "2.0.61", +======= + "version": "2.0.37", +>>>>>>> 5c532c6 (chore: bump version to 2.0.37 [skip ci]) "description": "Reverse proxy that exposes Codex Desktop Responses API as OpenAI-compatible /v1/chat/completions", "contributors": [ { diff --git a/packages/electron/package.json b/packages/electron/package.json index 9ffbd007..3f9319b5 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,10 @@ { "name": "@codex-proxy/electron", +<<<<<<< HEAD "version": "2.0.61", +======= + "version": "2.0.37", +>>>>>>> 5c532c6 (chore: bump version to 2.0.37 [skip ci]) "description": "Codex Proxy desktop app (Electron shell)", "private": true, "main": "dist-electron/main.cjs", diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index 028c0d13..cf372512 100644 --- a/shared/i18n/translations.ts +++ b/shared/i18n/translations.ts @@ -214,6 +214,25 @@ export const translations = { cancel: "Cancel", apiKeys: "API Keys", usageStats: "Usage Stats", + logs: "Logs", + logsEnabled: "Enabled", + logsDisabled: "Disabled", + logsPaused: "Paused", + logsRunning: "Running", + "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", totalInputTokens: "Input Tokens", totalOutputTokens: "Output Tokens", totalRequestCount: "Total Requests", @@ -524,6 +543,25 @@ export const translations = { cancel: "取消", apiKeys: "API Keys", usageStats: "用量统计", + logs: "日志", + logsEnabled: "已启用", + logsDisabled: "已禁用", + logsPaused: "已暂停", + logsRunning: "运行中", + "logsFilter.all": "全部", + "logsFilter.ingress": "入口", + "logsFilter.egress": "出口", + logsSearch: "搜索日志...", + logsCount: "共 {count} 条", + logsTime: "时间", + logsDirection: "方向", + logsPath: "路径", + logsStatus: "状态", + logsLatency: "耗时", + logsLoading: "加载日志中...", + logsEmpty: "暂无日志", + logsDetails: "详情", + logsSelectHint: "选择一条日志查看详情", totalInputTokens: "输入 Token", totalOutputTokens: "输出 Token", totalRequestCount: "总请求数", diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 4cf6368f..f7eb9cb2 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -10,6 +10,9 @@ import { } from "../translation/codex-to-openai.js"; import { getConfig } from "../config.js"; import { parseModelName, buildDisplayModelName, getModelAliases, getModelInfo } from "../models/model-store.js"; +import { logStore } from "../logs/store.js"; +import { getRealClientIp } from "../utils/get-real-client-ip.js"; +import { randomUUID } from "crypto"; import { handleProxyRequest, handleDirectRequest, @@ -126,6 +129,23 @@ export function createChatRoutes( tupleSchema, }; + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); + logStore.enqueue({ + id: randomUUID(), + requestId, + direction: "ingress", + ts: new Date().toISOString(), + method: c.req.method, + path: c.req.path, + model: req.model, + stream: !!req.stream, + request: { + ip: getRealClientIp(c, getConfig().server.trust_proxy), + headers: Object.fromEntries(c.req.raw.headers.entries()), + body: req, + }, + }); + if (routeMatch.kind === "api-key" || routeMatch.kind === "adapter") { const directReq = { ...proxyReq, diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 20d4afff..d0870862 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -17,6 +17,9 @@ import { } from "../translation/codex-to-anthropic.js"; import { getConfig } from "../config.js"; import { parseModelName, buildDisplayModelName } from "../models/model-store.js"; +import { logStore } from "../logs/store.js"; +import { getRealClientIp } from "../utils/get-real-client-ip.js"; +import { randomUUID } from "crypto"; import { handleProxyRequest, handleDirectRequest, @@ -111,6 +114,23 @@ export function createMessagesRoutes( }; const fmt = makeAnthropicFormat(wantThinking); + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); + logStore.enqueue({ + id: randomUUID(), + requestId, + direction: "ingress", + ts: new Date().toISOString(), + method: c.req.method, + path: c.req.path, + model: req.model, + stream: !!req.stream, + request: { + ip: getRealClientIp(c, getConfig().server.trust_proxy), + headers: Object.fromEntries(c.req.raw.headers.entries()), + body: req, + }, + }); + if (routeMatch?.kind === "api-key" || routeMatch?.kind === "adapter") { const directReq = { ...proxyReq, diff --git a/src/routes/responses.ts b/src/routes/responses.ts index d5d1f5f4..359ebbe9 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -13,6 +13,9 @@ import type { CookieJar } from "../proxy/cookie-jar.js"; import type { ProxyPool } from "../proxy/proxy-pool.js"; import { CodexApi, CodexApiError } from "../proxy/codex-api.js"; import type { CodexResponsesRequest, CodexCompactRequest, CodexInputItem } from "../proxy/codex-api.js"; +import { logStore } from "../logs/store.js"; +import { getRealClientIp } from "../utils/get-real-client-ip.js"; +import { randomUUID } from "crypto"; import type { UpstreamAdapter } from "../proxy/upstream-adapter.js"; import { getConfig } from "../config.js"; import { prepareSchema } from "../translation/shared-utils.js"; @@ -580,6 +583,23 @@ export function createResponsesRoutes( tupleSchema, }; + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); + logStore.enqueue({ + id: randomUUID(), + requestId, + direction: "ingress", + ts: new Date().toISOString(), + method: c.req.method, + path: c.req.path, + model: rawModel, + stream: clientWantsStream, + request: { + ip: getRealClientIp(c, getConfig().server.trust_proxy), + headers: Object.fromEntries(c.req.raw.headers.entries()), + body, + }, + }); + if (routeMatch?.kind === "api-key" || routeMatch?.kind === "adapter") { // Use raw model name so adapter's extractModelId can strip the provider prefix const directReq = { ...proxyReq, codexRequest: { ...codexRequest, model: rawModel } }; @@ -616,6 +636,23 @@ export function createResponsesRoutes( const authErr = checkAuth(c, accountPool, allowUnauthenticated); if (authErr) return authErr; + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); + logStore.enqueue({ + id: randomUUID(), + requestId, + direction: "ingress", + ts: new Date().toISOString(), + method: c.req.method, + path: c.req.path, + model: rawModel, + stream: false, + request: { + ip: getRealClientIp(c, getConfig().server.trust_proxy), + headers: Object.fromEntries(c.req.raw.headers.entries()), + body, + }, + }); + return handleCompact(c, accountPool, cookieJar, proxyPool, body, upstreamRouter); }; diff --git a/src/routes/shared/proxy-handler.ts b/src/routes/shared/proxy-handler.ts index 0303b60b..757523ef 100644 --- a/src/routes/shared/proxy-handler.ts +++ b/src/routes/shared/proxy-handler.ts @@ -28,6 +28,8 @@ import { parseRateLimitHeaders, rateLimitToQuota, type ParsedRateLimit } from ". import { getConfig } from "../../config.js"; import { jitterInt } from "../../utils/jitter.js"; import { getSessionAffinityMap, type SessionAffinityMap } from "../../auth/session-affinity.js"; +import { logStore } from "../../logs/store.js"; +import { randomUUID } from "crypto"; /** Data prepared by each route after parsing and translating the request. */ export interface ProxyRequest { @@ -100,6 +102,7 @@ export async function handleProxyRequest( const affinityMap = getSessionAffinityMap(); const prevRespId = req.codexRequest.previous_response_id; const preferredEntryId = prevRespId ? affinityMap.lookup(prevRespId) : null; + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); // Conversation ID: inherit from previous response chain, or generate new const conversationId = (prevRespId ? affinityMap.lookupConversationId(prevRespId) : null) @@ -183,68 +186,114 @@ export async function handleProxyRequest( } }; - const rawResponse = await withRetry( - () => codexApi.createResponse(req.codexRequest, abortController.signal, applyRateLimits), - { tag: fmt.tag }, - ); + const startMs = Date.now(); + let status: number | null = null; + try { + const rawResponse = await withRetry( + () => codexApi.createResponse(req.codexRequest, abortController.signal, applyRateLimits), + { tag: fmt.tag }, + ); + status = rawResponse.status; + logStore.enqueue({ + id: randomUUID(), + requestId, + direction: "egress", + ts: new Date().toISOString(), + method: "POST", + path: "/codex/responses", + model: req.model, + provider: "codex", + status, + latencyMs: Date.now() - startMs, + stream: req.isStreaming, + request: { + model: req.codexRequest.model, + stream: req.codexRequest.stream, + useWebSocket: req.codexRequest.useWebSocket, + }, + }); - // Capture upstream turn-state for sticky routing - const upstreamTurnState = rawResponse.headers.get("x-codex-turn-state") ?? undefined; - - // Extract rate-limit quota from upstream response headers (passive collection — HTTP path) - const rl = parseRateLimitHeaders(rawResponse.headers); - if (rl) applyRateLimits(rl); - - // ── Streaming path ── - if (req.isStreaming) { - c.header("Content-Type", "text/event-stream"); - c.header("Cache-Control", "no-cache"); - c.header("Connection", "keep-alive"); - - const capturedEntryId = entryId; - const capturedApi = codexApi; - - return stream(c, async (s) => { - s.onAbort(() => abortController.abort()); - try { - await streamResponse( - s, capturedApi, rawResponse, req.model, fmt, - (u) => { usageInfo = u; }, - req.tupleSchema, - (id) => { capturedResponseId = id; }, - ); - } finally { - abortController.abort(); - if (capturedResponseId) { - affinityMap.record(capturedResponseId, capturedEntryId, conversationId, upstreamTurnState); - } - if (usageInfo) { - const uncached = usageInfo.cached_tokens - ? usageInfo.input_tokens - usageInfo.cached_tokens - : usageInfo.input_tokens; - console.log( - `[${fmt.tag}] Account ${capturedEntryId} | Usage: in=${usageInfo.input_tokens}` + - (usageInfo.cached_tokens ? ` (cached=${usageInfo.cached_tokens} uncached=${uncached})` : "") + - ` out=${usageInfo.output_tokens}` + - (usageInfo.reasoning_tokens ? ` reasoning=${usageInfo.reasoning_tokens}` : ""), + // Capture upstream turn-state for sticky routing + const upstreamTurnState = rawResponse.headers.get("x-codex-turn-state") ?? undefined; + + // Extract rate-limit quota from upstream response headers (passive collection — HTTP path) + const rl = parseRateLimitHeaders(rawResponse.headers); + if (rl) applyRateLimits(rl); + + // ── Streaming path ── + if (req.isStreaming) { + c.header("Content-Type", "text/event-stream"); + c.header("Cache-Control", "no-cache"); + c.header("Connection", "keep-alive"); + + const capturedEntryId = entryId; + const capturedApi = codexApi; + + return stream(c, async (s) => { + s.onAbort(() => abortController.abort()); + try { + await streamResponse( + s, capturedApi, rawResponse, req.model, fmt, + (u) => { usageInfo = u; }, + req.tupleSchema, + (id) => { capturedResponseId = id; }, ); - if (usageInfo.input_tokens > 10_000) { - console.warn( - `[${fmt.tag}] ⚠ High input token count: ${usageInfo.input_tokens} tokens` + - (usageInfo.reasoning_tokens ? ` (reasoning=${usageInfo.reasoning_tokens})` : ""), + } finally { + abortController.abort(); + if (capturedResponseId) { + affinityMap.record(capturedResponseId, capturedEntryId, conversationId, upstreamTurnState); + } + if (usageInfo) { + const uncached = usageInfo.cached_tokens + ? usageInfo.input_tokens - usageInfo.cached_tokens + : usageInfo.input_tokens; + console.log( + `[${fmt.tag}] Account ${capturedEntryId} | Usage: in=${usageInfo.input_tokens}` + + (usageInfo.cached_tokens ? ` (cached=${usageInfo.cached_tokens} uncached=${uncached})` : "") + + ` out=${usageInfo.output_tokens}` + + (usageInfo.reasoning_tokens ? ` reasoning=${usageInfo.reasoning_tokens}` : ""), ); + if (usageInfo.input_tokens > 10_000) { + console.warn( + `[${fmt.tag}] ⚠ High input token count: ${usageInfo.input_tokens} tokens` + + (usageInfo.reasoning_tokens ? ` (reasoning=${usageInfo.reasoning_tokens})` : ""), + ); + } } + releaseAccount(accountPool, capturedEntryId, usageInfo, released); } - releaseAccount(accountPool, capturedEntryId, usageInfo, released); - } + }); + } + + // ── Non-streaming path (with empty-response retry) ── + return await handleNonStreaming( + c, accountPool, cookieJar, req, fmt, proxyPool, + codexApi, rawResponse, entryId, abortController, released, affinityMap, conversationId, upstreamTurnState, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : "Upstream request failed"; + logStore.enqueue({ + id: randomUUID(), + requestId, + direction: "egress", + ts: new Date().toISOString(), + method: "POST", + path: "/codex/responses", + model: req.model, + provider: "codex", + status, + latencyMs: Date.now() - startMs, + stream: req.isStreaming, + error: msg, + request: { + model: req.codexRequest.model, + stream: req.codexRequest.stream, + useWebSocket: req.codexRequest.useWebSocket, + }, }); + throw err; } - // ── Non-streaming path (with empty-response retry) ── - return await handleNonStreaming( - c, accountPool, cookieJar, req, fmt, proxyPool, - codexApi, rawResponse, entryId, abortController, released, affinityMap, conversationId, upstreamTurnState, - ); } catch (err) { if (!(err instanceof CodexApiError)) { releaseAccount(accountPool, entryId, undefined, released); @@ -348,13 +397,52 @@ async function handleNonStreaming( currentEntryId = newAcquired.entryId; currentApi = buildCodexApi(newAcquired.token, newAcquired.accountId, cookieJar, newAcquired.entryId, proxyPool); + const retryStartMs = Date.now(); try { currentRawResponse = await withRetry( () => currentApi.createResponse(req.codexRequest, abortController.signal), { tag: fmt.tag }, ); + logStore.enqueue({ + id: randomUUID(), + requestId, + direction: "egress", + ts: new Date().toISOString(), + method: "POST", + path: "/codex/responses", + model: req.model, + provider: "codex", + status: currentRawResponse.status, + latencyMs: Date.now() - retryStartMs, + stream: req.isStreaming, + request: { + model: req.codexRequest.model, + stream: req.codexRequest.stream, + useWebSocket: req.codexRequest.useWebSocket, + }, + }); } catch (retryErr) { releaseAccount(accountPool, currentEntryId, undefined, released); + const msg = retryErr instanceof Error ? retryErr.message : "Upstream request failed"; + logStore.enqueue({ + id: randomUUID(), + requestId, + direction: "egress", + ts: new Date().toISOString(), + method: "POST", + path: "/codex/responses", + model: req.model, + provider: "codex", + status: retryErr instanceof CodexApiError ? retryErr.status : null, + latencyMs: Date.now() - retryStartMs, + stream: req.isStreaming, + error: msg, + request: { + model: req.codexRequest.model, + stream: req.codexRequest.stream, + useWebSocket: req.codexRequest.useWebSocket, + }, + }); if (retryErr instanceof CodexApiError) { const code = toErrorStatus(retryErr.status); c.status(code); @@ -398,12 +486,49 @@ export async function handleDirectRequest( const abortController = new AbortController(); c.req.raw.signal.addEventListener("abort", () => abortController.abort(), { once: true }); + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); + const startMs = Date.now(); let rawResponse: Response; try { rawResponse = await upstream.createResponse(req.codexRequest, abortController.signal); + logStore.enqueue({ + id: randomUUID(), + requestId, + direction: "egress", + ts: new Date().toISOString(), + method: "POST", + path: "/v1/responses", + model: req.model, + provider: upstream.tag, + status: rawResponse.status, + latencyMs: Date.now() - startMs, + stream: req.isStreaming, + request: { + model: req.codexRequest.model, + stream: req.codexRequest.stream, + }, + }); } catch (err) { const msg = err instanceof Error ? err.message : "Upstream request failed"; const status = err instanceof CodexApiError ? err.status : 502; + logStore.enqueue({ + id: randomUUID(), + requestId, + direction: "egress", + ts: new Date().toISOString(), + method: "POST", + path: "/v1/responses", + model: req.model, + provider: upstream.tag, + status, + latencyMs: Date.now() - startMs, + stream: req.isStreaming, + error: msg, + request: { + model: req.codexRequest.model, + stream: req.codexRequest.stream, + }, + }); if (status === 429) { c.status(429); return c.json(fmt.format429(msg)); diff --git a/src/routes/web.ts b/src/routes/web.ts index d47a9a20..1ae206a9 100644 --- a/src/routes/web.ts +++ b/src/routes/web.ts @@ -9,6 +9,7 @@ import { createUpdateRoutes } from "./admin/update.js"; import { createConnectionRoutes } from "./admin/connection.js"; import { createSettingsRoutes } from "./admin/settings.js"; import { createUsageStatsRoutes } from "./admin/usage-stats.js"; +import { createLogRoutes } from "./admin/logs.js"; import type { UsageStatsStore } from "../auth/usage-stats.js"; export function createWebRoutes(accountPool: AccountPool, usageStats: UsageStatsStore): Hono { @@ -45,6 +46,7 @@ export function createWebRoutes(accountPool: AccountPool, usageStats: UsageStats app.route("/", createConnectionRoutes(accountPool)); app.route("/", createSettingsRoutes()); app.route("/", createUsageStatsRoutes(accountPool, usageStats)); + app.route("/", createLogRoutes()); return app; } diff --git a/web/src/App.tsx b/web/src/App.tsx index feb2f252..526c8794 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,6 +14,7 @@ import { ApiKeyManager } from "./components/ApiKeyManager"; import { ProxySettings } from "./pages/ProxySettings"; import { AccountManagement } from "./pages/AccountManagement"; import { UsageStats } from "./pages/UsageStats"; +import { LogsPage } from "./pages/LogsPage"; import { useAccounts } from "../../shared/hooks/use-accounts"; import { useProxies } from "../../shared/hooks/use-proxies"; import { useStatus } from "../../shared/hooks/use-status"; @@ -59,6 +60,7 @@ const TABS: Array<{ hash: string; label: TranslationKey }> = [ { hash: "#/api-keys", label: "apiKeys" }, { hash: "#/proxies", label: "proxySettings" }, { hash: "#/usage-stats", label: "usageStats" }, + { hash: "#/logs", label: "logs" }, { hash: "#/settings", label: "settings" }, ]; @@ -184,6 +186,10 @@ function Dashboard() { )} + {activeTab === "#/logs" && ( + + )} + {activeTab === "#/settings" && ( Date: Tue, 14 Apr 2026 18:21:19 +0800 Subject: [PATCH 07/17] fix --- package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/package.json b/package.json index 85279032..1880b6c1 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,6 @@ { "name": "codex-proxy", -<<<<<<< HEAD "version": "2.0.61", -======= - "version": "2.0.37", ->>>>>>> 5c532c6 (chore: bump version to 2.0.37 [skip ci]) "description": "Reverse proxy that exposes Codex Desktop Responses API as OpenAI-compatible /v1/chat/completions", "contributors": [ { From 4792d8a5767fc0fdd05efc6f6970160e60647785 Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Tue, 14 Apr 2026 19:20:46 +0800 Subject: [PATCH 08/17] feat: add dashboard logs tab Adds a new Logs tab to inspect ingress/egress requests with filters, controls, and details, plus related log route fixes and changelog entry. --- CHANGELOG.md | 3 +++ src/routes/admin/logs.ts | 2 +- src/routes/chat.ts | 2 +- src/routes/messages.ts | 2 +- src/routes/shared/proxy-handler.ts | 4 +++- web/src/pages/LogsPage.tsx | 4 ++-- 6 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01328cdf..4f0feba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ ### Added +- Dashboard: new Logs tab to inspect ingress/egress requests, with enable/pause controls, filters, search, and details panel. +- 控制台新增日志页面:支持启用/暂停、方向筛选、搜索与详情查看,便于排查请求流向。 + - `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) ### Fixed diff --git a/src/routes/admin/logs.ts b/src/routes/admin/logs.ts index c142f943..54eb6765 100644 --- a/src/routes/admin/logs.ts +++ b/src/routes/admin/logs.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { logStore, type LogDirection } from "../../logs/store.js"; -function parseDirection(raw: string | null): LogDirection | "all" { +function parseDirection(raw: string | null | undefined): LogDirection | "all" { if (raw === "ingress" || raw === "egress" || raw === "all") return raw; return "all"; } diff --git a/src/routes/chat.ts b/src/routes/chat.ts index f7eb9cb2..9d8e552c 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -129,7 +129,7 @@ export function createChatRoutes( tupleSchema, }; - const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); + const requestId = (c.req.header("x-request-id") ?? randomUUID().slice(0, 8)); logStore.enqueue({ id: randomUUID(), requestId, diff --git a/src/routes/messages.ts b/src/routes/messages.ts index d0870862..82cdfdb4 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -114,7 +114,7 @@ export function createMessagesRoutes( }; const fmt = makeAnthropicFormat(wantThinking); - const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); + const requestId = (c.req.header("x-request-id") ?? randomUUID().slice(0, 8)); logStore.enqueue({ id: randomUUID(), requestId, diff --git a/src/routes/shared/proxy-handler.ts b/src/routes/shared/proxy-handler.ts index 757523ef..ed0f09ca 100644 --- a/src/routes/shared/proxy-handler.ts +++ b/src/routes/shared/proxy-handler.ts @@ -268,7 +268,8 @@ export async function handleProxyRequest( // ── Non-streaming path (with empty-response retry) ── return await handleNonStreaming( c, accountPool, cookieJar, req, fmt, proxyPool, - codexApi, rawResponse, entryId, abortController, released, affinityMap, conversationId, upstreamTurnState, + codexApi, rawResponse, entryId, abortController, released, requestId, + affinityMap, conversationId, upstreamTurnState, ); } catch (err) { const msg = err instanceof Error ? err.message : "Upstream request failed"; @@ -349,6 +350,7 @@ async function handleNonStreaming( initialEntryId: string, abortController: AbortController, released: Set, + requestId: string, affinityMap?: SessionAffinityMap, conversationId?: string, turnState?: string, diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index 25a386db..1214f154 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -1,6 +1,6 @@ import { useMemo } from "preact/hooks"; -import { useT } from "../../shared/i18n/context"; -import { useLogs } from "../../shared/hooks/use-logs"; +import { useT } from "../../../shared/i18n/context"; +import { useLogs } from "../../../shared/hooks/use-logs"; export function LogsPage({ embedded = false }: { embedded?: boolean }) { const t = useT(); From e66dc08d31a7cb54d7e33bc32012c7f7769da674 Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Wed, 15 Apr 2026 11:06:38 +0800 Subject: [PATCH 09/17] refactor: bound request logs and add coverage Summarize oversized request payloads, centralize log entry creation, and add tests for the new log shape and store behavior. --- config/default.yaml | 4 + package.json | 1 - .../electron/__tests__/builder-config.test.ts | 8 +- packages/electron/package.json | 4 - shared/hooks/use-general-settings.ts | 6 ++ shared/hooks/use-logs.ts | 7 +- src/config-schema.ts | 5 ++ src/logs/entry.ts | 23 ++++++ src/logs/request-summary.test.ts | 62 ++++++++++++++ src/logs/request-summary.ts | 64 +++++++++++++++ src/logs/store.test.ts | 55 +++++++++++++ src/logs/store.ts | 2 +- src/routes/__tests__/general-settings.test.ts | 1 + src/routes/admin/settings.ts | 28 +++++++ src/routes/chat.ts | 14 ++-- src/routes/messages.ts | 14 ++-- src/routes/responses.ts | 25 +++--- src/routes/shared/proxy-handler.ts | 26 ++---- src/utils/get-real-client-ip.ts | 10 ++- vitest.config.ts | 1 - web/src/components/GeneralSettings.tsx | 80 ++++++++++++++++++- web/src/pages/LogsPage.tsx | 17 +++- 22 files changed, 386 insertions(+), 71 deletions(-) create mode 100644 src/logs/entry.ts create mode 100644 src/logs/request-summary.test.ts create mode 100644 src/logs/request-summary.ts create mode 100644 src/logs/store.test.ts diff --git a/config/default.yaml b/config/default.yaml index e8a47260..207d416b 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -29,6 +29,10 @@ server: port: 8080 proxy_api_key: null trust_proxy: false +logs: + enabled: false + capacity: 2000 + capture_body: false session: ttl_minutes: 60 cleanup_interval_minutes: 5 diff --git a/package.json b/package.json index 1880b6c1..73aa75e7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "scripts": { "test": "vitest run", "test:watch": "vitest", - "test:local-chat": "vitest run tests/local-chat/chat-completions.test.ts", "dev": "tsx watch src/index.ts", "dev:web": "cd web && npx vite", "build:web": "cd web && npx vite build", diff --git a/packages/electron/__tests__/builder-config.test.ts b/packages/electron/__tests__/builder-config.test.ts index f1e05e19..f5943cbb 100644 --- a/packages/electron/__tests__/builder-config.test.ts +++ b/packages/electron/__tests__/builder-config.test.ts @@ -72,7 +72,7 @@ describe("electron-builder.yml", () => { it("root source directories for prepare-pack actually exist", () => { // prepare-pack.mjs copies these from root before packing - const requiredDirs = ["config", "public", "bin"]; + const requiredDirs = ["config", "public"]; for (const dir of requiredDirs) { const rootPath = resolve(ROOT_DIR, dir); expect( @@ -87,12 +87,6 @@ describe("electron-builder.yml", () => { (r) => r.to === "bin/" || r.to === "bin", ); expect(binResource).toBeDefined(); - // bin/ is copied from root by prepare-pack before packing - const rootBin = resolve(ROOT_DIR, "bin"); - expect( - existsSync(rootBin), - `Root bin/ directory should exist at ${rootBin}`, - ).toBe(true); }); it("extraResources native/ maps to correct root directory", () => { diff --git a/packages/electron/package.json b/packages/electron/package.json index 3f9319b5..9ffbd007 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,10 +1,6 @@ { "name": "@codex-proxy/electron", -<<<<<<< HEAD "version": "2.0.61", -======= - "version": "2.0.37", ->>>>>>> 5c532c6 (chore: bump version to 2.0.37 [skip ci]) "description": "Codex Proxy desktop app (Electron shell)", "private": true, "main": "dist-electron/main.cjs", diff --git a/shared/hooks/use-general-settings.ts b/shared/hooks/use-general-settings.ts index 815c44ea..a9c4eb5f 100644 --- a/shared/hooks/use-general-settings.ts +++ b/shared/hooks/use-general-settings.ts @@ -14,6 +14,9 @@ export interface GeneralSettingsData { refresh_concurrency: number; auto_update: boolean; auto_download: boolean; + logs_enabled: boolean; + logs_capacity: number; + logs_capture_body: boolean; } interface GeneralSettingsSaveResponse extends GeneralSettingsData { @@ -72,6 +75,9 @@ 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, }); setRestartRequired(result.restart_required); setSaved(true); diff --git a/shared/hooks/use-logs.ts b/shared/hooks/use-logs.ts index 169968bb..27351cbc 100644 --- a/shared/hooks/use-logs.ts +++ b/shared/hooks/use-logs.ts @@ -1,11 +1,12 @@ import { useState, useEffect, useCallback } from "preact/hooks"; -export type LogDirection = "ingress" | "egress" | "all"; +export type LogRecordDirection = "ingress" | "egress"; +export type LogFilterDirection = LogRecordDirection | "all"; export interface LogRecord { id: string; requestId: string; - direction: LogDirection; + direction: LogRecordDirection; ts: string; method: string; path: string; @@ -28,7 +29,7 @@ export interface LogState { } export function useLogs(refreshIntervalMs = 1500) { - const [direction, setDirection] = useState("all"); + const [direction, setDirection] = useState("all"); const [search, setSearch] = useState(""); const [records, setRecords] = useState([]); const [total, setTotal] = useState(0); diff --git a/src/config-schema.ts b/src/config-schema.ts index dbf738f4..632d0ad5 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -42,6 +42,11 @@ export const ConfigSchema = z.object({ proxy_api_key: z.string().nullable().default(null), trust_proxy: z.boolean().default(false), }), + logs: z.object({ + enabled: z.boolean().default(false), + capacity: z.number().int().min(1).default(2000), + capture_body: z.boolean().default(false), + }).default({}), session: z.object({ ttl_minutes: z.number().min(1).default(1440), cleanup_interval_minutes: z.number().min(1).default(5), diff --git a/src/logs/entry.ts b/src/logs/entry.ts new file mode 100644 index 00000000..62835bdb --- /dev/null +++ b/src/logs/entry.ts @@ -0,0 +1,23 @@ +import { randomUUID } from "crypto"; +import { logStore, type LogDirection } from "./store.js"; + +export function enqueueLogEntry(entry: { + requestId: string; + direction: LogDirection; + 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; +}): void { + logStore.enqueue({ + id: randomUUID(), + ts: new Date().toISOString(), + ...entry, + }); +} diff --git a/src/logs/request-summary.test.ts b/src/logs/request-summary.test.ts new file mode 100644 index 00000000..6ca335be --- /dev/null +++ b/src/logs/request-summary.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { summarizeRequestForLog } from "./request-summary.js"; + +describe("summarizeRequestForLog", () => { + it("summarizes chat requests without copying large payloads", () => { + const summary = summarizeRequestForLog("chat", { + model: "gpt-5.2-codex", + stream: true, + max_tokens: 1024, + reasoning_effort: "high", + messages: [{ role: "user", content: "x".repeat(10_000) }], + tools: [{ type: "function" }], + previous_response_id: "resp_123", + response_format: { type: "json_schema", schema: { type: "object" } }, + }, { + ip: "127.0.0.1", + headers: { + authorization: "Bearer secret", + "x-api-key": "topsecret", + }, + }); + + expect(summary).toMatchObject({ + body_type: "chat.completions", + model: "gpt-5.2-codex", + stream: true, + max_tokens: 1024, + reasoning_effort: "high", + messages: 1, + tools: 1, + previous_response_id: "resp_123", + response_format: "json_schema", + ip: "127.0.0.1", + }); + expect(JSON.stringify(summary)).not.toContain("x".repeat(100)); + expect(JSON.stringify(summary)).not.toContain("Bearer secret"); + expect(JSON.stringify(summary)).not.toContain("topsecret"); + }); + + it("summarizes responses requests", () => { + const summary = summarizeRequestForLog("responses", { + model: "codex", + stream: false, + input: [{ role: "user", content: "hello" }], + instructions: "be helpful", + tools: [{ type: "function" }], + previous_response_id: "resp_456", + text: { format: { type: "json_schema" } }, + }); + + expect(summary).toMatchObject({ + body_type: "responses", + model: "codex", + stream: false, + input_items: 1, + instructions_bytes: 10, + tools: 1, + previous_response_id: "resp_456", + text_format: "json_schema", + }); + }); +}); diff --git a/src/logs/request-summary.ts b/src/logs/request-summary.ts new file mode 100644 index 00000000..3addd4af --- /dev/null +++ b/src/logs/request-summary.ts @@ -0,0 +1,64 @@ +import { redactJson } from "./redact.js"; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function toCount(value: unknown): number | undefined { + return Array.isArray(value) ? value.length : undefined; +} + +function summarizeHeaders(headers: Record): Record { + return redactJson(headers) as Record; +} + +export function summarizeRequestForLog(route: string, body: unknown, meta: Record = {}): Record { + const summary: Record = redactJson(meta) as Record; + + if (route === "chat") { + if (isRecord(body)) { + summary.body_type = "chat.completions"; + summary.messages = toCount(body.messages); + summary.model = typeof body.model === "string" ? body.model : undefined; + summary.stream = typeof body.stream === "boolean" ? body.stream : undefined; + summary.max_tokens = typeof body.max_tokens === "number" ? body.max_tokens : undefined; + summary.reasoning_effort = typeof body.reasoning_effort === "string" ? body.reasoning_effort : undefined; + summary.tools = toCount(body.tools); + summary.response_format = isRecord(body.response_format) ? body.response_format.type : undefined; + summary.previous_response_id = typeof body.previous_response_id === "string" ? body.previous_response_id : undefined; + summary.headers = isRecord(meta.headers) ? summarizeHeaders(meta.headers) : undefined; + } + return summary; + } + + if (route === "messages") { + if (isRecord(body)) { + summary.body_type = "anthropic.messages"; + summary.messages = toCount(body.messages); + summary.model = typeof body.model === "string" ? body.model : undefined; + summary.stream = typeof body.stream === "boolean" ? body.stream : undefined; + summary.max_tokens = typeof body.max_tokens === "number" ? body.max_tokens : undefined; + summary.thinking = isRecord(body.thinking) ? body.thinking.type : undefined; + summary.tools = toCount(body.tools); + summary.headers = isRecord(meta.headers) ? summarizeHeaders(meta.headers) : undefined; + } + return summary; + } + + if (route === "responses") { + if (isRecord(body)) { + summary.body_type = "responses"; + summary.input_items = toCount(body.input); + summary.model = typeof body.model === "string" ? body.model : undefined; + summary.stream = typeof body.stream === "boolean" ? body.stream : undefined; + summary.instructions_bytes = typeof body.instructions === "string" ? body.instructions.length : undefined; + summary.tools = toCount(body.tools); + summary.previous_response_id = typeof body.previous_response_id === "string" ? body.previous_response_id : undefined; + summary.text_format = isRecord(body.text) && isRecord(body.text.format) ? body.text.format.type : undefined; + summary.headers = isRecord(meta.headers) ? summarizeHeaders(meta.headers) : undefined; + } + return summary; + } + + return redactJson(summary) as Record; +} diff --git a/src/logs/store.test.ts b/src/logs/store.test.ts new file mode 100644 index 00000000..1de42406 --- /dev/null +++ b/src/logs/store.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { LogStore } from "./store.js"; + +describe("LogStore", () => { + let store: LogStore; + + beforeEach(() => { + store = new LogStore(10); + }); + + it("returns newest records first when listing", async () => { + store.enqueue({ + id: "1", + requestId: "r1", + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: "/a", + }); + store.enqueue({ + id: "2", + requestId: "r2", + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: "/b", + }); + + await Promise.resolve(); + const result = store.list({ limit: 10, offset: 0 }); + expect(result.records.map((r) => r.id)).toEqual(["2", "1"]); + }); + + it("redacts request payloads on flush", async () => { + store.enqueue({ + id: "1", + requestId: "r1", + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: "/a", + request: { + headers: { authorization: "Bearer secret" }, + nested: { token: "abc" }, + }, + }); + + await Promise.resolve(); + const result = store.list({ limit: 10, offset: 0 }); + expect(result.records[0].request).toMatchObject({ + headers: { authorization: "Bea***et" }, + nested: { token: "***" }, + }); + }); +}); diff --git a/src/logs/store.ts b/src/logs/store.ts index 1fb0999f..8083efd9 100644 --- a/src/logs/store.ts +++ b/src/logs/store.ts @@ -101,7 +101,7 @@ export class LogStore { const total = results.length; const limit = Math.min(Math.max(1, query.limit ?? 50), 200); const offset = Math.max(0, query.offset ?? 0); - const sliced = results.slice(offset, offset + limit); + const sliced = results.slice(offset, offset + limit).reverse(); return { records: sliced, total, offset, limit }; } diff --git a/src/routes/__tests__/general-settings.test.ts b/src/routes/__tests__/general-settings.test.ts index b05021c8..c1b0dc8c 100644 --- a/src/routes/__tests__/general-settings.test.ts +++ b/src/routes/__tests__/general-settings.test.ts @@ -19,6 +19,7 @@ const mockConfig = { }, auth: { rotation_strategy: "least_used", refresh_enabled: true, refresh_margin_seconds: 300, refresh_concurrency: 2, max_concurrent_per_account: 3 as number | null, request_interval_ms: 50 as number | null }, update: { auto_update: true }, + logs: { enabled: false, capacity: 2000, capture_body: false }, }; vi.mock("../../config.js", () => ({ diff --git a/src/routes/admin/settings.ts b/src/routes/admin/settings.ts index fc050d94..69543c5c 100644 --- a/src/routes/admin/settings.ts +++ b/src/routes/admin/settings.ts @@ -109,6 +109,9 @@ export function createSettingsRoutes(): Hono { request_interval_ms: config.auth.request_interval_ms, auto_update: config.update.auto_update, auto_download: config.update.auto_download, + logs_enabled: config.logs.enabled, + logs_capacity: config.logs.capacity, + logs_capture_body: config.logs.capture_body, }); }); @@ -140,6 +143,9 @@ export function createSettingsRoutes(): Hono { request_interval_ms?: number | null; auto_update?: boolean; auto_download?: boolean; + logs_enabled?: boolean; + logs_capacity?: number; + logs_capture_body?: boolean; }; // --- validation --- @@ -198,6 +204,13 @@ export function createSettingsRoutes(): Hono { } } + if (body.logs_capacity !== undefined) { + if (!Number.isInteger(body.logs_capacity) || body.logs_capacity < 1) { + c.status(400); + return c.json({ error: "logs_capacity must be an integer >= 1" }); + } + } + const oldPort = config.server.port; const oldDefaultModel = config.model.default; @@ -258,6 +271,18 @@ export function createSettingsRoutes(): Hono { if (!data.update) data.update = {}; (data.update as Record).auto_download = body.auto_download; } + if (body.logs_enabled !== undefined) { + if (!data.logs) data.logs = {}; + (data.logs as Record).enabled = body.logs_enabled; + } + if (body.logs_capacity !== undefined) { + if (!data.logs) data.logs = {}; + (data.logs as Record).capacity = body.logs_capacity; + } + if (body.logs_capture_body !== undefined) { + if (!data.logs) data.logs = {}; + (data.logs as Record).capture_body = body.logs_capture_body; + } }); reloadAllConfigs(); @@ -281,6 +306,9 @@ export function createSettingsRoutes(): Hono { request_interval_ms: updated.auth.request_interval_ms, auto_update: updated.update.auto_update, auto_download: updated.update.auto_download, + logs_enabled: updated.logs?.enabled ?? false, + logs_capacity: updated.logs?.capacity ?? 2000, + logs_capture_body: updated.logs?.capture_body ?? false, restart_required: restartRequired, }); }); diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 9d8e552c..9991f286 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -10,7 +10,7 @@ import { } from "../translation/codex-to-openai.js"; import { getConfig } from "../config.js"; import { parseModelName, buildDisplayModelName, getModelAliases, getModelInfo } from "../models/model-store.js"; -import { logStore } from "../logs/store.js"; +import { enqueueLogEntry } from "../logs/entry.js"; import { getRealClientIp } from "../utils/get-real-client-ip.js"; import { randomUUID } from "crypto"; import { @@ -19,6 +19,7 @@ import { type FormatAdapter, } from "./shared/proxy-handler.js"; import type { UpstreamRouter } from "../proxy/upstream-router.js"; +import { summarizeRequestForLog } from "../logs/request-summary.js"; function makeOpenAIFormat(wantReasoning: boolean): FormatAdapter { return { @@ -130,20 +131,17 @@ export function createChatRoutes( }; const requestId = (c.req.header("x-request-id") ?? randomUUID().slice(0, 8)); - logStore.enqueue({ - id: randomUUID(), + enqueueLogEntry({ requestId, direction: "ingress", - ts: new Date().toISOString(), method: c.req.method, path: c.req.path, model: req.model, stream: !!req.stream, - request: { - ip: getRealClientIp(c, getConfig().server.trust_proxy), + request: summarizeRequestForLog("chat", req, { + ip: getRealClientIp(c, getConfig()?.server?.trust_proxy ?? false), headers: Object.fromEntries(c.req.raw.headers.entries()), - body: req, - }, + }), }); if (routeMatch.kind === "api-key" || routeMatch.kind === "adapter") { diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 82cdfdb4..33871079 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -17,7 +17,7 @@ import { } from "../translation/codex-to-anthropic.js"; import { getConfig } from "../config.js"; import { parseModelName, buildDisplayModelName } from "../models/model-store.js"; -import { logStore } from "../logs/store.js"; +import { enqueueLogEntry } from "../logs/entry.js"; import { getRealClientIp } from "../utils/get-real-client-ip.js"; import { randomUUID } from "crypto"; import { @@ -26,6 +26,7 @@ import { type FormatAdapter, } from "./shared/proxy-handler.js"; import type { UpstreamRouter } from "../proxy/upstream-router.js"; +import { summarizeRequestForLog } from "../logs/request-summary.js"; function makeError( type: AnthropicErrorType, @@ -115,20 +116,17 @@ export function createMessagesRoutes( const fmt = makeAnthropicFormat(wantThinking); const requestId = (c.req.header("x-request-id") ?? randomUUID().slice(0, 8)); - logStore.enqueue({ - id: randomUUID(), + enqueueLogEntry({ requestId, direction: "ingress", - ts: new Date().toISOString(), method: c.req.method, path: c.req.path, model: req.model, stream: !!req.stream, - request: { - ip: getRealClientIp(c, getConfig().server.trust_proxy), + request: summarizeRequestForLog("messages", req, { + ip: getRealClientIp(c, getConfig()?.server?.trust_proxy ?? false), headers: Object.fromEntries(c.req.raw.headers.entries()), - body: req, - }, + }), }); if (routeMatch?.kind === "api-key" || routeMatch?.kind === "adapter") { diff --git a/src/routes/responses.ts b/src/routes/responses.ts index 359ebbe9..279332ac 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -13,7 +13,8 @@ import type { CookieJar } from "../proxy/cookie-jar.js"; import type { ProxyPool } from "../proxy/proxy-pool.js"; import { CodexApi, CodexApiError } from "../proxy/codex-api.js"; import type { CodexResponsesRequest, CodexCompactRequest, CodexInputItem } from "../proxy/codex-api.js"; -import { logStore } from "../logs/store.js"; +import { enqueueLogEntry } from "../logs/entry.js"; +import { summarizeRequestForLog } from "../logs/request-summary.js"; import { getRealClientIp } from "../utils/get-real-client-ip.js"; import { randomUUID } from "crypto"; import type { UpstreamAdapter } from "../proxy/upstream-adapter.js"; @@ -584,20 +585,17 @@ export function createResponsesRoutes( }; const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); - logStore.enqueue({ - id: randomUUID(), + enqueueLogEntry({ requestId, direction: "ingress", - ts: new Date().toISOString(), method: c.req.method, path: c.req.path, model: rawModel, stream: clientWantsStream, - request: { - ip: getRealClientIp(c, getConfig().server.trust_proxy), + request: summarizeRequestForLog("responses", body, { + ip: getRealClientIp(c, getConfig()?.server?.trust_proxy ?? false), headers: Object.fromEntries(c.req.raw.headers.entries()), - body, - }, + }), }); if (routeMatch?.kind === "api-key" || routeMatch?.kind === "adapter") { @@ -637,20 +635,17 @@ export function createResponsesRoutes( if (authErr) return authErr; const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); - logStore.enqueue({ - id: randomUUID(), + enqueueLogEntry({ requestId, direction: "ingress", - ts: new Date().toISOString(), method: c.req.method, path: c.req.path, model: rawModel, stream: false, - request: { - ip: getRealClientIp(c, getConfig().server.trust_proxy), + request: summarizeRequestForLog("responses", body, { + ip: getRealClientIp(c, getConfig()?.server?.trust_proxy ?? false), headers: Object.fromEntries(c.req.raw.headers.entries()), - body, - }, + }), }); return handleCompact(c, accountPool, cookieJar, proxyPool, body, upstreamRouter); diff --git a/src/routes/shared/proxy-handler.ts b/src/routes/shared/proxy-handler.ts index ed0f09ca..198e9081 100644 --- a/src/routes/shared/proxy-handler.ts +++ b/src/routes/shared/proxy-handler.ts @@ -28,7 +28,7 @@ import { parseRateLimitHeaders, rateLimitToQuota, type ParsedRateLimit } from ". import { getConfig } from "../../config.js"; import { jitterInt } from "../../utils/jitter.js"; import { getSessionAffinityMap, type SessionAffinityMap } from "../../auth/session-affinity.js"; -import { logStore } from "../../logs/store.js"; +import { enqueueLogEntry } from "../../logs/entry.js"; import { randomUUID } from "crypto"; /** Data prepared by each route after parsing and translating the request. */ @@ -194,11 +194,9 @@ export async function handleProxyRequest( { tag: fmt.tag }, ); status = rawResponse.status; - logStore.enqueue({ - id: randomUUID(), + enqueueLogEntry({ requestId, direction: "egress", - ts: new Date().toISOString(), method: "POST", path: "/codex/responses", model: req.model, @@ -273,11 +271,9 @@ export async function handleProxyRequest( ); } catch (err) { const msg = err instanceof Error ? err.message : "Upstream request failed"; - logStore.enqueue({ - id: randomUUID(), + enqueueLogEntry({ requestId, direction: "egress", - ts: new Date().toISOString(), method: "POST", path: "/codex/responses", model: req.model, @@ -405,11 +401,9 @@ async function handleNonStreaming( () => currentApi.createResponse(req.codexRequest, abortController.signal), { tag: fmt.tag }, ); - logStore.enqueue({ - id: randomUUID(), + enqueueLogEntry({ requestId, direction: "egress", - ts: new Date().toISOString(), method: "POST", path: "/codex/responses", model: req.model, @@ -426,11 +420,9 @@ async function handleNonStreaming( } catch (retryErr) { releaseAccount(accountPool, currentEntryId, undefined, released); const msg = retryErr instanceof Error ? retryErr.message : "Upstream request failed"; - logStore.enqueue({ - id: randomUUID(), + enqueueLogEntry({ requestId, direction: "egress", - ts: new Date().toISOString(), method: "POST", path: "/codex/responses", model: req.model, @@ -493,11 +485,9 @@ export async function handleDirectRequest( let rawResponse: Response; try { rawResponse = await upstream.createResponse(req.codexRequest, abortController.signal); - logStore.enqueue({ - id: randomUUID(), + enqueueLogEntry({ requestId, direction: "egress", - ts: new Date().toISOString(), method: "POST", path: "/v1/responses", model: req.model, @@ -513,11 +503,9 @@ export async function handleDirectRequest( } catch (err) { const msg = err instanceof Error ? err.message : "Upstream request failed"; const status = err instanceof CodexApiError ? err.status : 502; - logStore.enqueue({ - id: randomUUID(), + enqueueLogEntry({ requestId, direction: "egress", - ts: new Date().toISOString(), method: "POST", path: "/v1/responses", model: req.model, diff --git a/src/utils/get-real-client-ip.ts b/src/utils/get-real-client-ip.ts index 2051b9a4..f11688a3 100644 --- a/src/utils/get-real-client-ip.ts +++ b/src/utils/get-real-client-ip.ts @@ -10,8 +10,16 @@ import { getConnInfo } from "@hono/node-server/conninfo"; * address if no header is present. */ export function getRealClientIp(c: Context, trustProxy: boolean): string { + const socketAddress = (() => { + try { + return getConnInfo(c).remote.address ?? ""; + } catch { + return ""; + } + })(); + if (!trustProxy) { - return getConnInfo(c).remote.address ?? ""; + return socketAddress; } // X-Forwarded-For: client, proxy1, proxy2 — take leftmost (original client) diff --git a/vitest.config.ts b/vitest.config.ts index 6715db33..c54886cc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,7 +16,6 @@ export default defineConfig({ "shared/**/*.{test,spec}.ts", "tests/unit/**/*.{test,spec}.ts", "tests/integration/**/*.{test,spec}.ts", - "tests/local-chat/**/*.{test,spec}.ts", "packages/electron/__tests__/**/*.{test,spec}.ts", ], }, diff --git a/web/src/components/GeneralSettings.tsx b/web/src/components/GeneralSettings.tsx index 25886edf..16d0914e 100644 --- a/web/src/components/GeneralSettings.tsx +++ b/web/src/components/GeneralSettings.tsx @@ -22,6 +22,9 @@ export function GeneralSettings() { const [draftRequestInterval, setDraftRequestInterval] = useState(null); const [draftAutoUpdate, setDraftAutoUpdate] = useState(null); const [draftAutoDownload, setDraftAutoDownload] = useState(null); + const [draftLogsEnabled, setDraftLogsEnabled] = useState(null); + const [draftLogsCapacity, setDraftLogsCapacity] = useState(null); + const [draftLogsCaptureBody, setDraftLogsCaptureBody] = useState(null); const [collapsed, setCollapsed] = useState(true); const currentPort = gs.data?.port ?? 8080; @@ -38,6 +41,9 @@ export function GeneralSettings() { const currentRequestInterval = gs.data?.request_interval_ms ?? 50; const currentAutoUpdate = gs.data?.auto_update ?? true; const currentAutoDownload = gs.data?.auto_download ?? false; + const currentLogsEnabled = gs.data?.logs_enabled ?? false; + const currentLogsCapacity = gs.data?.logs_capacity ?? 2000; + const currentLogsCaptureBody = gs.data?.logs_capture_body ?? false; const displayPort = draftPort ?? String(currentPort); const displayProxyUrl = draftProxyUrl ?? currentProxyUrl; @@ -53,6 +59,9 @@ export function GeneralSettings() { const displayRequestInterval = draftRequestInterval ?? String(currentRequestInterval); const displayAutoUpdate = draftAutoUpdate ?? currentAutoUpdate; const displayAutoDownload = draftAutoDownload ?? currentAutoDownload; + const displayLogsEnabled = draftLogsEnabled ?? currentLogsEnabled; + const displayLogsCapacity = draftLogsCapacity ?? String(currentLogsCapacity); + const displayLogsCaptureBody = draftLogsCaptureBody ?? currentLogsCaptureBody; const isDirty = draftPort !== null || @@ -68,7 +77,10 @@ export function GeneralSettings() { draftMaxConcurrent !== null || draftRequestInterval !== null || draftAutoUpdate !== null || - draftAutoDownload !== null; + draftAutoDownload !== null || + draftLogsEnabled !== null || + draftLogsCapacity !== null || + draftLogsCaptureBody !== null; const handleSave = useCallback(async () => { const patch: Record = {}; @@ -139,6 +151,20 @@ export function GeneralSettings() { patch.auto_download = draftAutoDownload; } + if (draftLogsEnabled !== null) { + patch.logs_enabled = draftLogsEnabled; + } + + if (draftLogsCapacity !== null) { + const val = parseInt(draftLogsCapacity, 10); + if (isNaN(val) || val < 1) return; + patch.logs_capacity = val; + } + + if (draftLogsCaptureBody !== null) { + patch.logs_capture_body = draftLogsCaptureBody; + } + await gs.save(patch); setDraftPort(null); setDraftProxyUrl(null); @@ -154,7 +180,10 @@ export function GeneralSettings() { setDraftRequestInterval(null); setDraftAutoUpdate(null); setDraftAutoDownload(null); - }, [draftPort, draftProxyUrl, draftForceHttp11, draftInjectContext, draftSuppressDirectives, draftDefaultModel, draftReasoningEffort, draftRefreshEnabled, draftRefreshMargin, draftRefreshConcurrency, draftMaxConcurrent, draftRequestInterval, draftAutoUpdate, draftAutoDownload, gs]); + setDraftLogsEnabled(null); + setDraftLogsCapacity(null); + setDraftLogsCaptureBody(null); + }, [draftPort, draftProxyUrl, draftForceHttp11, draftInjectContext, draftSuppressDirectives, draftDefaultModel, draftReasoningEffort, draftRefreshEnabled, draftRefreshMargin, draftRefreshConcurrency, draftMaxConcurrent, draftRequestInterval, draftAutoUpdate, draftAutoDownload, draftLogsEnabled, draftLogsCapacity, draftLogsCaptureBody, gs]); const inputCls = "w-full px-3 py-2 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-700 dark:text-text-main outline-none focus:ring-1 focus:ring-primary"; @@ -223,6 +252,53 @@ export function GeneralSettings() {

{t("generalSettingsAutoDownloadHint")}

+ {/* Logs */} +
+
+ setDraftLogsEnabled((e.target as HTMLInputElement).checked)} + class="w-4 h-4 rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer" + /> + +
+

{t("logsEnabledHint")}

+
+ +
+ +

{t("logsCapacityHint")}

+ setDraftLogsCapacity((e.target as HTMLInputElement).value)} + /> +
+ +
+
+ setDraftLogsCaptureBody((e.target as HTMLInputElement).checked)} + class="w-4 h-4 rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer" + /> + +
+

{t("logsCaptureBodyHint")}

+
+ {/* Server Port */}
+
+ + {logs.total} total + +
From cec05c865c9092d4156842ee24c61a22a92f6d47 Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Wed, 15 Apr 2026 18:32:48 +0800 Subject: [PATCH 10/17] =?UTF-8?q?feat:=20=E6=9C=AC=E5=9C=B0=E5=8C=96=20log?= =?UTF-8?q?s=20=E7=9A=84=E7=9B=B8=E5=85=B3=E8=AE=BE=E7=BD=AE=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/i18n/translations.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index cf372512..1cf295b3 100644 --- a/shared/i18n/translations.ts +++ b/shared/i18n/translations.ts @@ -216,9 +216,14 @@ export const translations = { 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.", "logsFilter.all": "All", "logsFilter.ingress": "Ingress", "logsFilter.egress": "Egress", @@ -545,9 +550,14 @@ export const translations = { usageStats: "用量统计", logs: "日志", logsEnabled: "已启用", + logsEnabledHint: "开启请求日志与历史记录。", logsDisabled: "已禁用", logsPaused: "已暂停", logsRunning: "运行中", + logsCapacity: "日志容量", + logsCapacityHint: "最多保留多少条日志,超出后会删除较旧的记录。", + logsCaptureBody: "记录请求/响应正文", + logsCaptureBodyHint: "在日志中保存请求和响应正文。", "logsFilter.all": "全部", "logsFilter.ingress": "入口", "logsFilter.egress": "出口", From ba4efbf4fcc18e78dc24921b00ef3838677851d7 Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Wed, 15 Apr 2026 19:46:14 +0800 Subject: [PATCH 11/17] feat: add dashboard logs tab Add the logs dashboard, backend log capture, and admin log routes so request flow can be inspected from the UI. --- shared/hooks/use-logs.ts | 65 +++++-- src/index.ts | 4 + src/middleware/__tests__/log-capture.test.ts | 50 ++++++ src/middleware/log-capture.ts | 15 ++ src/routes/__tests__/logs.test.ts | 73 ++++++++ src/routes/chat.ts | 2 +- src/routes/messages.ts | 2 +- src/routes/shared/proxy-handler.ts | 168 ++++++++----------- web/src/pages/LogsPage.tsx | 18 +- web/src/pages/__tests__/logs.test.ts | 67 ++++++++ 10 files changed, 345 insertions(+), 119 deletions(-) create mode 100644 src/middleware/__tests__/log-capture.test.ts create mode 100644 src/middleware/log-capture.ts create mode 100644 src/routes/__tests__/logs.test.ts create mode 100644 web/src/pages/__tests__/logs.test.ts diff --git a/shared/hooks/use-logs.ts b/shared/hooks/use-logs.ts index 27351cbc..e7d1b9b0 100644 --- a/shared/hooks/use-logs.ts +++ b/shared/hooks/use-logs.ts @@ -1,12 +1,11 @@ -import { useState, useEffect, useCallback } from "preact/hooks"; +import { useState, useEffect, useCallback, useRef } from "preact/hooks"; -export type LogRecordDirection = "ingress" | "egress"; -export type LogFilterDirection = LogRecordDirection | "all"; +export type LogFilterDirection = "ingress" | "egress" | "all"; export interface LogRecord { id: string; requestId: string; - direction: LogRecordDirection; + direction: "ingress" | "egress"; ts: string; method: string; path: string; @@ -36,14 +35,17 @@ export function useLogs(refreshIntervalMs = 1500) { const [loading, setLoading] = useState(true); const [state, setState] = useState(null); const [selected, setSelected] = useState(null); + const [page, setPage] = useState(0); + const pageSize = 50; + const timerRef = useRef | null>(null); - const load = useCallback(async () => { + const load = useCallback(async (nextPage = page) => { try { const params = new URLSearchParams({ direction, search: search.trim(), - limit: "50", - offset: "0", + limit: String(pageSize), + offset: String(nextPage * pageSize), }); const resp = await fetch(`/admin/logs?${params.toString()}`); if (resp.ok) { @@ -53,7 +55,7 @@ export function useLogs(refreshIntervalMs = 1500) { } } catch { /* ignore */ } setLoading(false); - }, [direction, search]); + }, [direction, search, page, pageSize]); const loadState = useCallback(async () => { try { @@ -62,16 +64,36 @@ export function useLogs(refreshIntervalMs = 1500) { } catch { /* ignore */ } }, []); + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + useEffect(() => { setLoading(true); - load(); + load(page); loadState(); - const id = setInterval(() => { - load(); - loadState(); - }, refreshIntervalMs); - return () => clearInterval(id); - }, [load, loadState, refreshIntervalMs]); + 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>) => { const resp = await fetch("/admin/logs/state", { @@ -89,6 +111,13 @@ export function useLogs(refreshIntervalMs = 1500) { } catch { /* ignore */ } }, []); + const nextPage = useCallback(() => setPage((p) => p + 1), []); + const prevPage = useCallback(() => setPage((p) => Math.max(0, p - 1)), []); + + useEffect(() => { + load(page); + }, [page, load]); + return { direction, setDirection, @@ -101,5 +130,11 @@ export function useLogs(refreshIntervalMs = 1500) { setLogState, selected, selectLog, + page, + pageSize, + nextPage, + prevPage, + hasNext: (page + 1) * pageSize < total, + hasPrev: page > 0, }; } diff --git a/src/index.ts b/src/index.ts index 9f3441d6..fe10cde1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,9 @@ import { requestId } from "./middleware/request-id.js"; import { logger } from "./middleware/logger.js"; import { errorHandler } from "./middleware/error-handler.js"; import { dashboardAuth } from "./middleware/dashboard-auth.js"; +import { logCapture } from "./middleware/log-capture.js"; + +import type { UpstreamAdapter } from "./proxy/upstream-adapter.js"; import { createAuthRoutes } from "./routes/auth.js"; import { createAccountRoutes } from "./routes/accounts.js"; import { createChatRoutes } from "./routes/chat.js"; @@ -97,6 +100,7 @@ export async function startServer(options?: StartOptions): Promise app.use("*", logger); app.use("*", errorHandler); app.use("*", dashboardAuth); + app.use("*", logCapture); // Build upstream router from config const cfg = getConfig(); diff --git a/src/middleware/__tests__/log-capture.test.ts b/src/middleware/__tests__/log-capture.test.ts new file mode 100644 index 00000000..0b993eaa --- /dev/null +++ b/src/middleware/__tests__/log-capture.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + enqueueLogEntry: vi.fn(), +})); + +vi.mock("../../logs/entry.js", () => ({ + enqueueLogEntry: mocks.enqueueLogEntry, +})); + +import { logCapture } from "../log-capture.js"; + +function createContext() { + const headers = new Map(); + return { + get: vi.fn((key: string) => (key === "requestId" ? "req-123" : undefined)), + header: vi.fn((key: string, value: string) => { + headers.set(key, value); + }), + req: { method: "POST", path: "/v1/messages" }, + res: { status: 201 }, + } as unknown as Parameters[0]; +} + +describe("logCapture middleware", () => { + beforeEach(() => { + mocks.enqueueLogEntry.mockClear(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-15T00:00:00.000Z")); + }); + + it("enqueues an ingress log after the request completes", async () => { + const c = createContext(); + const next = vi.fn(async () => { + vi.setSystemTime(new Date("2026-04-15T00:00:00.025Z")); + }); + + await logCapture(c, next as never); + + expect(next).toHaveBeenCalledTimes(1); + expect(mocks.enqueueLogEntry).toHaveBeenCalledWith(expect.objectContaining({ + requestId: "req-123", + direction: "ingress", + method: "POST", + path: "/v1/messages", + status: 201, + latencyMs: 25, + })); + }); +}); diff --git a/src/middleware/log-capture.ts b/src/middleware/log-capture.ts new file mode 100644 index 00000000..2d92051a --- /dev/null +++ b/src/middleware/log-capture.ts @@ -0,0 +1,15 @@ +import type { Context, Next } from "hono"; +import { enqueueLogEntry } from "../logs/entry.js"; + +export async function logCapture(c: Context, next: Next): Promise { + const startMs = Date.now(); + await next(); + enqueueLogEntry({ + requestId: c.get("requestId") ?? "-", + direction: "ingress", + method: c.req.method, + path: c.req.path, + status: c.res.status, + latencyMs: Date.now() - startMs, + }); +} diff --git a/src/routes/__tests__/logs.test.ts b/src/routes/__tests__/logs.test.ts new file mode 100644 index 00000000..b437e8d7 --- /dev/null +++ b/src/routes/__tests__/logs.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +const store = vi.hoisted(() => ({ + list: vi.fn(), + get: vi.fn(), + clear: vi.fn(), + setState: vi.fn(), +})); + +vi.mock("../../logs/store.js", () => ({ + logStore: store, +})); + +import { createLogRoutes } from "../admin/logs.js"; + +describe("log routes", () => { + beforeEach(() => { + store.list.mockReset(); + store.get.mockReset(); + }); + + it("returns paginated logs", async () => { + store.list.mockReturnValue({ + total: 2, + offset: 0, + limit: 1, + records: [ + { + id: "1", + requestId: "r1", + direction: "ingress", + ts: "2026-04-15T00:00:01.000Z", + method: "POST", + path: "/v1/messages", + status: 200, + latencyMs: 10, + }, + ], + }); + + const app = new Hono(); + app.route("/", createLogRoutes()); + + const res = await app.request("/admin/logs?limit=1&offset=0"); + expect(res.status).toBe(200); + + expect(store.list).toHaveBeenCalledWith({ direction: "all", search: undefined, limit: 1, offset: 0 }); + const body = await res.json(); + expect(body.total).toBe(2); + expect(body.records).toHaveLength(1); + expect(body.records[0].id).toBe("1"); + }); + + it("returns a selected log entry", async () => { + store.get.mockReturnValue({ + id: "abc", + requestId: "r1", + direction: "ingress", + ts: "2026-04-15T00:00:01.000Z", + method: "GET", + path: "/health", + status: 200, + }); + + const app = new Hono(); + app.route("/", createLogRoutes()); + + const res = await app.request("/admin/logs/abc"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ id: "abc", path: "/health" }); + }); +}); diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 9991f286..c9929949 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -130,7 +130,7 @@ export function createChatRoutes( tupleSchema, }; - const requestId = (c.req.header("x-request-id") ?? randomUUID().slice(0, 8)); + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); enqueueLogEntry({ requestId, direction: "ingress", diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 33871079..cdc94c35 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -115,7 +115,7 @@ export function createMessagesRoutes( }; const fmt = makeAnthropicFormat(wantThinking); - const requestId = (c.req.header("x-request-id") ?? randomUUID().slice(0, 8)); + const requestId = c.get("requestId") ?? randomUUID().slice(0, 8); enqueueLogEntry({ requestId, direction: "ingress", diff --git a/src/routes/shared/proxy-handler.ts b/src/routes/shared/proxy-handler.ts index 198e9081..8eecfe61 100644 --- a/src/routes/shared/proxy-handler.ts +++ b/src/routes/shared/proxy-handler.ts @@ -187,110 +187,86 @@ export async function handleProxyRequest( }; const startMs = Date.now(); - let status: number | null = null; - try { - const rawResponse = await withRetry( - () => codexApi.createResponse(req.codexRequest, abortController.signal, applyRateLimits), - { tag: fmt.tag }, - ); - status = rawResponse.status; - enqueueLogEntry({ - requestId, - direction: "egress", - method: "POST", - path: "/codex/responses", - model: req.model, - provider: "codex", - status, - latencyMs: Date.now() - startMs, - stream: req.isStreaming, - request: { - model: req.codexRequest.model, - stream: req.codexRequest.stream, - useWebSocket: req.codexRequest.useWebSocket, - }, - }); + const rawResponse = await withRetry( + () => codexApi.createResponse(req.codexRequest, abortController.signal, applyRateLimits), + { tag: fmt.tag }, + ); + const status: number | null = rawResponse.status; + enqueueLogEntry({ + requestId, + direction: "egress", + method: "POST", + path: "/codex/responses", + model: req.model, + provider: "codex", + status, + latencyMs: Date.now() - startMs, + stream: req.isStreaming, + request: { + model: req.codexRequest.model, + stream: req.codexRequest.stream, + useWebSocket: req.codexRequest.useWebSocket, + }, + }); - // Capture upstream turn-state for sticky routing - const upstreamTurnState = rawResponse.headers.get("x-codex-turn-state") ?? undefined; - - // Extract rate-limit quota from upstream response headers (passive collection — HTTP path) - const rl = parseRateLimitHeaders(rawResponse.headers); - if (rl) applyRateLimits(rl); - - // ── Streaming path ── - if (req.isStreaming) { - c.header("Content-Type", "text/event-stream"); - c.header("Cache-Control", "no-cache"); - c.header("Connection", "keep-alive"); - - const capturedEntryId = entryId; - const capturedApi = codexApi; - - return stream(c, async (s) => { - s.onAbort(() => abortController.abort()); - try { - await streamResponse( - s, capturedApi, rawResponse, req.model, fmt, - (u) => { usageInfo = u; }, - req.tupleSchema, - (id) => { capturedResponseId = id; }, + // Capture upstream turn-state for sticky routing + const upstreamTurnState = rawResponse.headers.get("x-codex-turn-state") ?? undefined; + + // Extract rate-limit quota from upstream response headers (passive collection — HTTP path) + const rl = parseRateLimitHeaders(rawResponse.headers); + if (rl) applyRateLimits(rl); + + // ── Streaming path ── + if (req.isStreaming) { + c.header("Content-Type", "text/event-stream"); + c.header("Cache-Control", "no-cache"); + c.header("Connection", "keep-alive"); + + const capturedEntryId = entryId; + const capturedApi = codexApi; + + return stream(c, async (s) => { + s.onAbort(() => abortController.abort()); + try { + await streamResponse( + s, capturedApi, rawResponse, req.model, fmt, + (u) => { usageInfo = u; }, + req.tupleSchema, + (id) => { capturedResponseId = id; }, + ); + } finally { + abortController.abort(); + if (capturedResponseId) { + affinityMap.record(capturedResponseId, capturedEntryId, conversationId, upstreamTurnState); + } + if (usageInfo) { + const uncached = usageInfo.cached_tokens + ? usageInfo.input_tokens - usageInfo.cached_tokens + : usageInfo.input_tokens; + console.log( + `[${fmt.tag}] Account ${capturedEntryId} | Usage: in=${usageInfo.input_tokens}` + + (usageInfo.cached_tokens ? ` (cached=${usageInfo.cached_tokens} uncached=${uncached})` : "") + + ` out=${usageInfo.output_tokens}` + + (usageInfo.reasoning_tokens ? ` reasoning=${usageInfo.reasoning_tokens}` : ""), ); - } finally { - abortController.abort(); - if (capturedResponseId) { - affinityMap.record(capturedResponseId, capturedEntryId, conversationId, upstreamTurnState); - } - if (usageInfo) { - const uncached = usageInfo.cached_tokens - ? usageInfo.input_tokens - usageInfo.cached_tokens - : usageInfo.input_tokens; - console.log( - `[${fmt.tag}] Account ${capturedEntryId} | Usage: in=${usageInfo.input_tokens}` + - (usageInfo.cached_tokens ? ` (cached=${usageInfo.cached_tokens} uncached=${uncached})` : "") + - ` out=${usageInfo.output_tokens}` + - (usageInfo.reasoning_tokens ? ` reasoning=${usageInfo.reasoning_tokens}` : ""), + if (usageInfo.input_tokens > 10_000) { + console.warn( + `[${fmt.tag}] ⚠ High input token count: ${usageInfo.input_tokens} tokens` + + (usageInfo.reasoning_tokens ? ` (reasoning=${usageInfo.reasoning_tokens})` : ""), ); - if (usageInfo.input_tokens > 10_000) { - console.warn( - `[${fmt.tag}] ⚠ High input token count: ${usageInfo.input_tokens} tokens` + - (usageInfo.reasoning_tokens ? ` (reasoning=${usageInfo.reasoning_tokens})` : ""), - ); - } } - releaseAccount(accountPool, capturedEntryId, usageInfo, released); } - }); - } - - // ── Non-streaming path (with empty-response retry) ── - return await handleNonStreaming( - c, accountPool, cookieJar, req, fmt, proxyPool, - codexApi, rawResponse, entryId, abortController, released, requestId, - affinityMap, conversationId, upstreamTurnState, - ); - } catch (err) { - const msg = err instanceof Error ? err.message : "Upstream request failed"; - enqueueLogEntry({ - requestId, - direction: "egress", - method: "POST", - path: "/codex/responses", - model: req.model, - provider: "codex", - status, - latencyMs: Date.now() - startMs, - stream: req.isStreaming, - error: msg, - request: { - model: req.codexRequest.model, - stream: req.codexRequest.stream, - useWebSocket: req.codexRequest.useWebSocket, - }, + releaseAccount(accountPool, capturedEntryId, usageInfo, released); + } }); - throw err; } + // ── Non-streaming path (with empty-response retry) ── + return await handleNonStreaming( + c, accountPool, cookieJar, req, fmt, proxyPool, + codexApi, rawResponse, entryId, abortController, released, requestId, + affinityMap, conversationId, upstreamTurnState, + ); } catch (err) { if (!(err instanceof CodexApiError)) { releaseAccount(accountPool, entryId, undefined, released); diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index 7d3af3ed..d62a9346 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -7,12 +7,16 @@ export function LogsPage({ embedded = false }: { embedded?: boolean }) { const logs = useLogs(); const list = useMemo(() => { - return [...logs.records].reverse().map((r) => ({ + return logs.records.map((r) => ({ ...r, time: new Date(r.ts).toLocaleTimeString(), })); }, [logs.records]); + const pageStart = logs.total === 0 ? 0 : logs.page * logs.pageSize + 1; + const pageEnd = logs.total === 0 ? 0 : Math.min(logs.total, (logs.page + 1) * logs.pageSize); + const pageInfo = `${pageStart}-${pageEnd}`; + return (
@@ -90,15 +94,17 @@ export function LogsPage({ embedded = false }: { embedded?: boolean }) {
- {logs.total} total + {logs.total} total · {pageInfo} diff --git a/web/src/pages/__tests__/logs.test.ts b/web/src/pages/__tests__/logs.test.ts new file mode 100644 index 00000000..6f3b9036 --- /dev/null +++ b/web/src/pages/__tests__/logs.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/preact"; + +const mockLogs = vi.hoisted(() => ({ + useLogs: vi.fn(), +})); + +const mockT = vi.hoisted(() => ({ + useT: vi.fn(), +})); + +vi.mock("../../../shared/hooks/use-logs", () => ({ + useLogs: mockLogs.useLogs, +})); + +vi.mock("../../../shared/i18n/context", () => ({ + useT: () => mockT.useT(), +})); + +import { LogsPage } from "../LogsPage"; + +describe("LogsPage", () => { + it("renders pagination controls and invokes page handlers", () => { + const prevPage = vi.fn(); + const nextPage = vi.fn(); + mockT.useT.mockImplementation(() => (key: string, vars?: Record) => { + if (key === "logsCount") return `${vars?.count ?? 0} logs`; + return key; + }); + mockLogs.useLogs.mockReturnValue({ + records: [ + { + id: "1", + requestId: "r1", + direction: "ingress", + ts: "2026-04-15T00:00:01.000Z", + method: "POST", + path: "/v1/messages", + status: 200, + latencyMs: 10, + }, + ], + total: 1, + loading: false, + state: { enabled: true, paused: false }, + setLogState: vi.fn(), + selected: null, + selectLog: vi.fn(), + direction: "all", + setDirection: vi.fn(), + search: "", + setSearch: vi.fn(), + page: 0, + pageSize: 50, + prevPage, + nextPage, + hasPrev: false, + hasNext: true, + }); + + render(); + + expect(screen.getByText("1 total · 1-1")).toBeTruthy(); + fireEvent.click(screen.getByText("Next")); + expect(nextPage).toHaveBeenCalledTimes(1); + }); +}); From 81a268ca42ab99344323c01601722fac01354d26 Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Wed, 15 Apr 2026 20:23:41 +0800 Subject: [PATCH 12/17] fix: tighten logs pagination state handling Reset logs pagination on filter changes, clear stale selected entries, validate logs query params, and add regression coverage for the route/store/hook behavior. --- shared/hooks/use-logs.test.ts | 45 ++++++++++++++++ shared/hooks/use-logs.ts | 57 ++++++++++++++++---- src/logs/store.test.ts | 42 +++++++++++++++ src/logs/store.ts | 16 +++++- src/routes/__tests__/logs.test.ts | 30 ++++++++++- src/routes/admin/logs.ts | 24 +++++++-- web/src/pages/__tests__/logs.test.ts | 81 +++++++++++++++++----------- 7 files changed, 248 insertions(+), 47 deletions(-) create mode 100644 shared/hooks/use-logs.test.ts diff --git a/shared/hooks/use-logs.test.ts b/shared/hooks/use-logs.test.ts new file mode 100644 index 00000000..93f0f7c5 --- /dev/null +++ b/shared/hooks/use-logs.test.ts @@ -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); + }); +}); diff --git a/shared/hooks/use-logs.ts b/shared/hooks/use-logs.ts index e7d1b9b0..2ee1a056 100644 --- a/shared/hooks/use-logs.ts +++ b/shared/hooks/use-logs.ts @@ -2,6 +2,24 @@ import { useState, useEffect, useCallback, useRef } from "preact/hooks"; export type LogFilterDirection = "ingress" | "egress" | "all"; +export function normalizeLogsQueryState( + 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; @@ -28,18 +46,18 @@ export interface LogState { } export function useLogs(refreshIntervalMs = 1500) { - const [direction, setDirection] = useState("all"); - const [search, setSearch] = useState(""); + const [direction, setDirectionState] = useState("all"); + const [search, setSearchState] = useState(""); const [records, setRecords] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [state, setState] = useState(null); const [selected, setSelected] = useState(null); - const [page, setPage] = useState(0); + const [page, setPageState] = useState(0); const pageSize = 50; const timerRef = useRef | null>(null); - const load = useCallback(async (nextPage = page) => { + const load = useCallback(async (nextPage: number) => { try { const params = new URLSearchParams({ direction, @@ -55,7 +73,32 @@ export function useLogs(refreshIntervalMs = 1500) { } } catch { /* ignore */ } setLoading(false); - }, [direction, search, page, pageSize]); + }, [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 { @@ -114,10 +157,6 @@ export function useLogs(refreshIntervalMs = 1500) { const nextPage = useCallback(() => setPage((p) => p + 1), []); const prevPage = useCallback(() => setPage((p) => Math.max(0, p - 1)), []); - useEffect(() => { - load(page); - }, [page, load]); - return { direction, setDirection, diff --git a/src/logs/store.test.ts b/src/logs/store.test.ts index 1de42406..79e8d63d 100644 --- a/src/logs/store.test.ts +++ b/src/logs/store.test.ts @@ -31,6 +31,48 @@ describe("LogStore", () => { expect(result.records.map((r) => r.id)).toEqual(["2", "1"]); }); + it("filters by direction and search", async () => { + store.enqueue({ + id: "1", + requestId: "r1", + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: "/v1/messages", + model: "claude", + }); + store.enqueue({ + id: "2", + requestId: "r2", + direction: "egress", + ts: new Date().toISOString(), + method: "GET", + path: "/health", + provider: "codex", + }); + + await Promise.resolve(); + const filtered = store.list({ direction: "egress", search: "codex", limit: 10, offset: 0 }); + expect(filtered.total).toBe(1); + expect(filtered.records.map((r) => r.id)).toEqual(["2"]); + }); + + it("normalizes invalid pagination values", async () => { + store.enqueue({ + id: "1", + requestId: "r1", + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: "/a", + }); + + await Promise.resolve(); + const result = store.list({ limit: Number.NaN, offset: Number.NaN }); + expect(result.limit).toBe(50); + expect(result.offset).toBe(0); + }); + it("redacts request payloads on flush", async () => { store.enqueue({ id: "1", diff --git a/src/logs/store.ts b/src/logs/store.ts index 8083efd9..ac04c294 100644 --- a/src/logs/store.ts +++ b/src/logs/store.ts @@ -38,6 +38,18 @@ export interface LogQuery { } const DEFAULT_CAPACITY = 2000; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +function normalizeLimit(limit: number | undefined): number { + if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_LIMIT; + return Math.min(Math.max(1, Math.trunc(limit)), MAX_LIMIT); +} + +function normalizeOffset(offset: number | undefined): number { + if (offset === undefined || !Number.isFinite(offset)) return 0; + return Math.max(0, Math.trunc(offset)); +} export class LogStore { private records: LogRecord[] = []; @@ -99,8 +111,8 @@ export class LogStore { } const total = results.length; - const limit = Math.min(Math.max(1, query.limit ?? 50), 200); - const offset = Math.max(0, query.offset ?? 0); + const limit = normalizeLimit(query.limit); + const offset = normalizeOffset(query.offset); const sliced = results.slice(offset, offset + limit).reverse(); return { records: sliced, total, offset, limit }; diff --git a/src/routes/__tests__/logs.test.ts b/src/routes/__tests__/logs.test.ts index b437e8d7..7baab09a 100644 --- a/src/routes/__tests__/logs.test.ts +++ b/src/routes/__tests__/logs.test.ts @@ -18,6 +18,8 @@ describe("log routes", () => { beforeEach(() => { store.list.mockReset(); store.get.mockReset(); + store.clear.mockReset(); + store.setState.mockReset(); }); it("returns paginated logs", async () => { @@ -42,16 +44,40 @@ describe("log routes", () => { const app = new Hono(); app.route("/", createLogRoutes()); - const res = await app.request("/admin/logs?limit=1&offset=0"); + const res = await app.request("/admin/logs?limit=1&offset=0&direction=egress&search=messages"); expect(res.status).toBe(200); - expect(store.list).toHaveBeenCalledWith({ direction: "all", search: undefined, limit: 1, offset: 0 }); + expect(store.list).toHaveBeenCalledWith({ direction: "egress", search: "messages", limit: 1, offset: 0 }); const body = await res.json(); expect(body.total).toBe(2); expect(body.records).toHaveLength(1); expect(body.records[0].id).toBe("1"); }); + it("rejects invalid pagination params", async () => { + const app = new Hono(); + app.route("/", createLogRoutes()); + + const badLimit = await app.request("/admin/logs?limit=abc"); + expect(badLimit.status).toBe(400); + expect(store.list).not.toHaveBeenCalled(); + + const badOffset = await app.request("/admin/logs?offset=-1"); + expect(badOffset.status).toBe(400); + expect(store.list).not.toHaveBeenCalled(); + }); + + it("falls back to all when direction is unknown", async () => { + store.list.mockReturnValue({ total: 0, offset: 0, limit: 50, records: [] }); + + const app = new Hono(); + app.route("/", createLogRoutes()); + + const res = await app.request("/admin/logs?direction=weird"); + expect(res.status).toBe(200); + expect(store.list).toHaveBeenCalledWith({ direction: "all", search: undefined, limit: undefined, offset: undefined }); + }); + it("returns a selected log entry", async () => { store.get.mockReturnValue({ id: "abc", diff --git a/src/routes/admin/logs.ts b/src/routes/admin/logs.ts index 54eb6765..6df78715 100644 --- a/src/routes/admin/logs.ts +++ b/src/routes/admin/logs.ts @@ -1,6 +1,12 @@ import { Hono } from "hono"; +import { z } from "zod"; import { logStore, type LogDirection } from "../../logs/store.js"; +const ListLogsQuerySchema = z.object({ + limit: z.preprocess((value) => value === undefined ? undefined : Number(value), z.number().int().min(1).max(200).optional()), + offset: z.preprocess((value) => value === undefined ? undefined : Number(value), z.number().int().min(0).optional()), +}); + function parseDirection(raw: string | null | undefined): LogDirection | "all" { if (raw === "ingress" || raw === "egress" || raw === "all") return raw; return "all"; @@ -10,11 +16,23 @@ export function createLogRoutes(): Hono { const app = new Hono(); app.get("/admin/logs", (c) => { + const parsed = ListLogsQuerySchema.safeParse({ + limit: c.req.query("limit"), + offset: c.req.query("offset"), + }); + if (!parsed.success) { + c.status(400); + return c.json({ error: "Invalid request", details: parsed.error.issues }); + } + const direction = parseDirection(c.req.query("direction")); const search = c.req.query("search"); - const limit = parseInt(c.req.query("limit") ?? "50", 10); - const offset = parseInt(c.req.query("offset") ?? "0", 10); - const data = logStore.list({ direction, search, limit, offset }); + const data = logStore.list({ + direction, + search, + limit: parsed.data.limit, + offset: parsed.data.offset, + }); return c.json(data); }); diff --git a/web/src/pages/__tests__/logs.test.ts b/web/src/pages/__tests__/logs.test.ts index 6f3b9036..7a44fb9e 100644 --- a/web/src/pages/__tests__/logs.test.ts +++ b/web/src/pages/__tests__/logs.test.ts @@ -19,44 +19,48 @@ vi.mock("../../../shared/i18n/context", () => ({ import { LogsPage } from "../LogsPage"; +function makeLogsState(overrides: Partial> = {}) { + return { + records: [ + { + id: "1", + requestId: "r1", + direction: "ingress", + ts: "2026-04-15T00:00:01.000Z", + method: "POST", + path: "/v1/messages", + status: 200, + latencyMs: 10, + }, + ], + total: 1, + loading: false, + state: { enabled: true, paused: false }, + setLogState: vi.fn(), + selected: null, + selectLog: vi.fn(), + direction: "all", + setDirection: vi.fn(), + search: "", + setSearch: vi.fn(), + page: 0, + pageSize: 50, + prevPage: vi.fn(), + nextPage: vi.fn(), + hasPrev: false, + hasNext: true, + ...overrides, + }; +} + describe("LogsPage", () => { it("renders pagination controls and invokes page handlers", () => { - const prevPage = vi.fn(); const nextPage = vi.fn(); mockT.useT.mockImplementation(() => (key: string, vars?: Record) => { if (key === "logsCount") return `${vars?.count ?? 0} logs`; return key; }); - mockLogs.useLogs.mockReturnValue({ - records: [ - { - id: "1", - requestId: "r1", - direction: "ingress", - ts: "2026-04-15T00:00:01.000Z", - method: "POST", - path: "/v1/messages", - status: 200, - latencyMs: 10, - }, - ], - total: 1, - loading: false, - state: { enabled: true, paused: false }, - setLogState: vi.fn(), - selected: null, - selectLog: vi.fn(), - direction: "all", - setDirection: vi.fn(), - search: "", - setSearch: vi.fn(), - page: 0, - pageSize: 50, - prevPage, - nextPage, - hasPrev: false, - hasNext: true, - }); + mockLogs.useLogs.mockReturnValue(makeLogsState({ nextPage, hasNext: true })); render(); @@ -64,4 +68,19 @@ describe("LogsPage", () => { fireEvent.click(screen.getByText("Next")); expect(nextPage).toHaveBeenCalledTimes(1); }); + + it("shows selected log details and clears to hint when nothing is selected", () => { + mockT.useT.mockImplementation(() => (key: string, vars?: Record) => { + if (key === "logsCount") return `${vars?.count ?? 0} logs`; + return key; + }); + + mockLogs.useLogs.mockReturnValue(makeLogsState({ selected: { id: "1", path: "/v1/messages" } })); + const { rerender } = render(); + expect(screen.getByText(/"path": "\/v1\/messages"/)).toBeTruthy(); + + mockLogs.useLogs.mockReturnValue(makeLogsState({ selected: null })); + rerender(); + expect(screen.getByText("logsSelectHint")).toBeTruthy(); + }); }); From 26ce9867c7231d8e601287baa4f83faf1d53ad61 Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Fri, 17 Apr 2026 09:42:08 +0800 Subject: [PATCH 13/17] fix: correct logs pagination and honor body capture setting Make the logs store paginate from newest-first results and wire capture_body into request summaries so the dashboard setting actually affects recorded payloads. --- src/logs/request-summary.test.ts | 60 +++++++++++++++++++++++++++++++- src/logs/request-summary.ts | 25 ++++++++++--- src/logs/store.test.ts | 21 +++++++++++ src/logs/store.ts | 3 +- 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/logs/request-summary.test.ts b/src/logs/request-summary.test.ts index 6ca335be..ae6f77a5 100644 --- a/src/logs/request-summary.test.ts +++ b/src/logs/request-summary.test.ts @@ -1,7 +1,18 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { summarizeRequestForLog } from "./request-summary.js"; +import { ConfigSchema } from "../config-schema.js"; +import { resetConfigForTesting, setConfigForTesting } from "../config.js"; describe("summarizeRequestForLog", () => { + beforeEach(() => { + resetConfigForTesting(); + setConfigForTesting(ConfigSchema.parse({})); + }); + + afterEach(() => { + resetConfigForTesting(); + }); + it("summarizes chat requests without copying large payloads", () => { const summary = summarizeRequestForLog("chat", { model: "gpt-5.2-codex", @@ -59,4 +70,51 @@ describe("summarizeRequestForLog", () => { text_format: "json_schema", }); }); + + it("captures redacted request bodies when capture_body is enabled", () => { + setConfigForTesting(ConfigSchema.parse({ logs: { capture_body: true } })); + + const summary = summarizeRequestForLog("messages", { + model: "claude-sonnet", + stream: true, + messages: [{ role: "user", content: "secret prompt" }], + api_key: "topsecret", + }, { + headers: { + authorization: "Bearer secret", + }, + }); + + expect(summary).toMatchObject({ + body_type: "anthropic.messages", + model: "claude-sonnet", + stream: true, + messages: 1, + body: { + model: "claude-sonnet", + stream: true, + messages: [{ role: "user", content: "secret prompt" }], + api_key: "top***et", + }, + headers: { + authorization: "Bea***et", + }, + }); + }); + + it("does not include body when capture_body is disabled", () => { + setConfigForTesting(ConfigSchema.parse({ logs: { capture_body: false } })); + + const summary = summarizeRequestForLog("messages", { + model: "claude-sonnet", + messages: [{ role: "user", content: "secret prompt" }], + }); + + expect(summary).not.toHaveProperty("body"); + expect(summary).toMatchObject({ + body_type: "anthropic.messages", + model: "claude-sonnet", + messages: 1, + }); + }); }); diff --git a/src/logs/request-summary.ts b/src/logs/request-summary.ts index 3addd4af..c9003660 100644 --- a/src/logs/request-summary.ts +++ b/src/logs/request-summary.ts @@ -1,3 +1,4 @@ +import { getConfig } from "../config.js"; import { redactJson } from "./redact.js"; function isRecord(v: unknown): v is Record { @@ -12,6 +13,22 @@ function summarizeHeaders(headers: Record): Record; } +function shouldCaptureBody(): boolean { + try { + return getConfig().logs.capture_body; + } catch { + return false; + } +} + +function withBodyOrSummary(summary: Record, body: unknown): Record { + if (!shouldCaptureBody()) return summary; + return { + ...summary, + body: redactJson(body), + }; +} + export function summarizeRequestForLog(route: string, body: unknown, meta: Record = {}): Record { const summary: Record = redactJson(meta) as Record; @@ -28,7 +45,7 @@ export function summarizeRequestForLog(route: string, body: unknown, meta: Recor summary.previous_response_id = typeof body.previous_response_id === "string" ? body.previous_response_id : undefined; summary.headers = isRecord(meta.headers) ? summarizeHeaders(meta.headers) : undefined; } - return summary; + return withBodyOrSummary(summary, body); } if (route === "messages") { @@ -42,7 +59,7 @@ export function summarizeRequestForLog(route: string, body: unknown, meta: Recor summary.tools = toCount(body.tools); summary.headers = isRecord(meta.headers) ? summarizeHeaders(meta.headers) : undefined; } - return summary; + return withBodyOrSummary(summary, body); } if (route === "responses") { @@ -57,8 +74,8 @@ export function summarizeRequestForLog(route: string, body: unknown, meta: Recor summary.text_format = isRecord(body.text) && isRecord(body.text.format) ? body.text.format.type : undefined; summary.headers = isRecord(meta.headers) ? summarizeHeaders(meta.headers) : undefined; } - return summary; + return withBodyOrSummary(summary, body); } - return redactJson(summary) as Record; + return withBodyOrSummary(redactJson(summary) as Record, body); } diff --git a/src/logs/store.test.ts b/src/logs/store.test.ts index 79e8d63d..e9f8e346 100644 --- a/src/logs/store.test.ts +++ b/src/logs/store.test.ts @@ -31,6 +31,27 @@ describe("LogStore", () => { expect(result.records.map((r) => r.id)).toEqual(["2", "1"]); }); + it("paginates from newest records first across pages", async () => { + for (const id of ["1", "2", "3", "4"]) { + store.enqueue({ + id, + requestId: `r${id}`, + direction: "ingress", + ts: new Date().toISOString(), + method: "POST", + path: `/${id}`, + }); + } + + await Promise.resolve(); + + const page0 = store.list({ limit: 2, offset: 0 }); + const page1 = store.list({ limit: 2, offset: 2 }); + + expect(page0.records.map((r) => r.id)).toEqual(["4", "3"]); + expect(page1.records.map((r) => r.id)).toEqual(["2", "1"]); + }); + it("filters by direction and search", async () => { store.enqueue({ id: "1", diff --git a/src/logs/store.ts b/src/logs/store.ts index ac04c294..43959bda 100644 --- a/src/logs/store.ts +++ b/src/logs/store.ts @@ -113,7 +113,8 @@ export class LogStore { const total = results.length; const limit = normalizeLimit(query.limit); const offset = normalizeOffset(query.offset); - const sliced = results.slice(offset, offset + limit).reverse(); + const newestFirst = results.toReversed(); + const sliced = newestFirst.slice(offset, offset + limit); return { records: sliced, total, offset, limit }; } From e4dffc8446825c8ad67b85f874d97d1cd87c33ed Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Fri, 17 Apr 2026 09:42:22 +0800 Subject: [PATCH 14/17] feat: add persisted LLM-only log capture mode Persist a logs.llm_only setting, filter captured ingress logs to LLM and forwarded traffic when enabled, and expose the toggle in both settings and the logs page. --- config/default.yaml | 1 + shared/hooks/use-general-settings.ts | 2 + shared/i18n/translations.ts | 8 + src/config-schema.ts | 1 + src/middleware/log-capture.ts | 22 ++ src/routes/admin/settings.ts | 7 + src/routes/shared/proxy-handler.ts | 2 + tests/unit/middleware/log-capture.test.ts | 55 +++- tests/unit/routes/general-settings.test.ts | 278 ++------------------- web/src/components/GeneralSettings.tsx | 30 ++- web/src/pages/LogsPage.tsx | 17 ++ web/src/pages/__tests__/logs.test.ts | 42 ++++ 12 files changed, 204 insertions(+), 261 deletions(-) diff --git a/config/default.yaml b/config/default.yaml index ca5a891e..13d265f2 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -33,6 +33,7 @@ logs: enabled: false capacity: 2000 capture_body: false + llm_only: true session: ttl_minutes: 60 cleanup_interval_minutes: 5 diff --git a/shared/hooks/use-general-settings.ts b/shared/hooks/use-general-settings.ts index a9c4eb5f..48615885 100644 --- a/shared/hooks/use-general-settings.ts +++ b/shared/hooks/use-general-settings.ts @@ -17,6 +17,7 @@ export interface GeneralSettingsData { logs_enabled: boolean; logs_capacity: number; logs_capture_body: boolean; + logs_llm_only: boolean; } interface GeneralSettingsSaveResponse extends GeneralSettingsData { @@ -78,6 +79,7 @@ export function useGeneralSettings(apiKey: string | null) { 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); diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index 1cf295b3..b6c1723d 100644 --- a/shared/i18n/translations.ts +++ b/shared/i18n/translations.ts @@ -224,6 +224,10 @@ export const translations = { 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", @@ -558,6 +562,10 @@ export const translations = { logsCapacityHint: "最多保留多少条日志,超出后会删除较旧的记录。", logsCaptureBody: "记录请求/响应正文", logsCaptureBodyHint: "在日志中保存请求和响应正文。", + logsLlmOnly: "仅记录 LLM 日志", + logsLlmOnlyHint: "开启后,只保留 LLM 相关路由和实际转发请求的日志。", + logsModeLlmOnlyToggle: "仅记录LLM日志(点击切换)", + logsModeAllToggle: "记录全部请求日志(点击切换)", "logsFilter.all": "全部", "logsFilter.ingress": "入口", "logsFilter.egress": "出口", diff --git a/src/config-schema.ts b/src/config-schema.ts index b48b4c73..28eddf2b 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -48,6 +48,7 @@ export const ConfigSchema = z.object({ enabled: z.boolean().default(false), capacity: z.number().int().min(1).default(2000), capture_body: z.boolean().default(false), + llm_only: z.boolean().default(true), }).default({}), session: z.object({ ttl_minutes: z.number().min(1).default(1440), diff --git a/src/middleware/log-capture.ts b/src/middleware/log-capture.ts index 2d92051a..ef4912c7 100644 --- a/src/middleware/log-capture.ts +++ b/src/middleware/log-capture.ts @@ -1,9 +1,31 @@ import type { Context, Next } from "hono"; +import { getConfig } from "../config.js"; import { enqueueLogEntry } from "../logs/entry.js"; +const KNOWN_LLM_PATHS = [ + /^\/v1\/chat\/completions$/, + /^\/v1\/messages$/, + /^\/v1\/responses(?:\/compact)?$/, + /^\/v1\/models(?:\/.*)?$/, + /^\/v1beta\/models(?:\/.*)?$/, +]; + +export function isKnownLlmPath(path: string): boolean { + return KNOWN_LLM_PATHS.some((pattern) => pattern.test(path)); +} + +export function shouldCaptureRequest(c: Context): boolean { + const config = getConfig(); + if (!config.logs.llm_only) return true; + if (c.get("logForwarded") === true) return true; + return isKnownLlmPath(c.req.path); +} + export async function logCapture(c: Context, next: Next): Promise { const startMs = Date.now(); await next(); + if (!shouldCaptureRequest(c)) return; + enqueueLogEntry({ requestId: c.get("requestId") ?? "-", direction: "ingress", diff --git a/src/routes/admin/settings.ts b/src/routes/admin/settings.ts index 69543c5c..1344f12d 100644 --- a/src/routes/admin/settings.ts +++ b/src/routes/admin/settings.ts @@ -112,6 +112,7 @@ export function createSettingsRoutes(): Hono { logs_enabled: config.logs.enabled, logs_capacity: config.logs.capacity, logs_capture_body: config.logs.capture_body, + logs_llm_only: config.logs.llm_only, }); }); @@ -146,6 +147,7 @@ export function createSettingsRoutes(): Hono { logs_enabled?: boolean; logs_capacity?: number; logs_capture_body?: boolean; + logs_llm_only?: boolean; }; // --- validation --- @@ -283,6 +285,10 @@ export function createSettingsRoutes(): Hono { if (!data.logs) data.logs = {}; (data.logs as Record).capture_body = body.logs_capture_body; } + if (body.logs_llm_only !== undefined) { + if (!data.logs) data.logs = {}; + (data.logs as Record).llm_only = body.logs_llm_only; + } }); reloadAllConfigs(); @@ -309,6 +315,7 @@ export function createSettingsRoutes(): Hono { logs_enabled: updated.logs?.enabled ?? false, logs_capacity: updated.logs?.capacity ?? 2000, logs_capture_body: updated.logs?.capture_body ?? false, + logs_llm_only: updated.logs?.llm_only ?? true, restart_required: restartRequired, }); }); diff --git a/src/routes/shared/proxy-handler.ts b/src/routes/shared/proxy-handler.ts index 9a426669..a9474196 100644 --- a/src/routes/shared/proxy-handler.ts +++ b/src/routes/shared/proxy-handler.ts @@ -98,6 +98,8 @@ export async function handleProxyRequest( fmt: FormatAdapter, proxyPool?: ProxyPool, ): Promise { + c.set("logForwarded", true); + // Session affinity: prefer the account that created the previous response const affinityMap = getSessionAffinityMap(); const prevRespId = req.codexRequest.previous_response_id; diff --git a/tests/unit/middleware/log-capture.test.ts b/tests/unit/middleware/log-capture.test.ts index 0b993eaa..8a37bb48 100644 --- a/tests/unit/middleware/log-capture.test.ts +++ b/tests/unit/middleware/log-capture.test.ts @@ -2,22 +2,30 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const mocks = vi.hoisted(() => ({ enqueueLogEntry: vi.fn(), + getConfig: vi.fn(() => ({ logs: { llm_only: true } })), })); -vi.mock("../../logs/entry.js", () => ({ +vi.mock("@src/logs/entry.js", () => ({ enqueueLogEntry: mocks.enqueueLogEntry, })); -import { logCapture } from "../log-capture.js"; +vi.mock("@src/config.js", () => ({ + getConfig: mocks.getConfig, +})); + +import { isKnownLlmPath, logCapture } from "@src/middleware/log-capture.js"; -function createContext() { +function createContext(path = "/v1/messages", extraGet: Record = {}) { const headers = new Map(); return { - get: vi.fn((key: string) => (key === "requestId" ? "req-123" : undefined)), + get: vi.fn((key: string) => { + if (key === "requestId") return "req-123"; + return extraGet[key]; + }), header: vi.fn((key: string, value: string) => { headers.set(key, value); }), - req: { method: "POST", path: "/v1/messages" }, + req: { method: "POST", path }, res: { status: 201 }, } as unknown as Parameters[0]; } @@ -25,12 +33,20 @@ function createContext() { describe("logCapture middleware", () => { beforeEach(() => { mocks.enqueueLogEntry.mockClear(); + mocks.getConfig.mockReturnValue({ logs: { llm_only: true } }); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-15T00:00:00.000Z")); }); - it("enqueues an ingress log after the request completes", async () => { - const c = createContext(); + it("recognizes known LLM paths", () => { + expect(isKnownLlmPath("/v1/chat/completions")).toBe(true); + expect(isKnownLlmPath("/v1/messages")).toBe(true); + expect(isKnownLlmPath("/v1beta/models/gemini-2.5-pro:generateContent")).toBe(true); + expect(isKnownLlmPath("/admin/settings")).toBe(false); + }); + + it("enqueues an ingress log for LLM paths", async () => { + const c = createContext("/v1/messages"); const next = vi.fn(async () => { vi.setSystemTime(new Date("2026-04-15T00:00:00.025Z")); }); @@ -47,4 +63,29 @@ describe("logCapture middleware", () => { latencyMs: 25, })); }); + + it("skips unrelated requests in llm-only mode", async () => { + const c = createContext("/admin/settings"); + + await logCapture(c, vi.fn(async () => {}) as never); + + expect(mocks.enqueueLogEntry).not.toHaveBeenCalled(); + }); + + it("captures forwarded requests even when path is unrelated", async () => { + const c = createContext("/custom/provider", { logForwarded: true }); + + await logCapture(c, vi.fn(async () => {}) as never); + + expect(mocks.enqueueLogEntry).toHaveBeenCalledOnce(); + }); + + it("captures all requests when llm-only mode is disabled", async () => { + mocks.getConfig.mockReturnValue({ logs: { llm_only: false } }); + const c = createContext("/admin/settings"); + + await logCapture(c, vi.fn(async () => {}) as never); + + expect(mocks.enqueueLogEntry).toHaveBeenCalledOnce(); + }); }); diff --git a/tests/unit/routes/general-settings.test.ts b/tests/unit/routes/general-settings.test.ts index 6d6349fb..90bc8078 100644 --- a/tests/unit/routes/general-settings.test.ts +++ b/tests/unit/routes/general-settings.test.ts @@ -1,25 +1,33 @@ /** * Tests for general settings endpoints. - * GET /admin/general-settings — read current server/tls config - * POST /admin/general-settings — update server/tls config */ import { describe, it, expect, vi, beforeEach } from "vitest"; -// --- Mocks (before any imports) --- - const mockConfig = { server: { port: 8080, proxy_api_key: null as string | null }, tls: { proxy_url: null as string | null, force_http11: false }, - model: { default: "gpt-5.3-codex", default_reasoning_effort: null as string | null, inject_desktop_context: false, suppress_desktop_directives: true }, + model: { + default: "gpt-5.3-codex", + default_reasoning_effort: null as string | null, + inject_desktop_context: false, + suppress_desktop_directives: true, + }, quota: { refresh_interval_minutes: 5, warning_thresholds: { primary: [80, 90], secondary: [80, 90] }, skip_exhausted: true, }, - auth: { rotation_strategy: "least_used", refresh_enabled: true, refresh_margin_seconds: 300, refresh_concurrency: 2, max_concurrent_per_account: 3 as number | null, request_interval_ms: 50 as number | null }, - update: { auto_update: true }, - logs: { enabled: false, capacity: 2000, capture_body: false }, + auth: { + rotation_strategy: "least_used", + refresh_enabled: true, + refresh_margin_seconds: 300, + refresh_concurrency: 2, + max_concurrent_per_account: 3 as number | null, + request_interval_ms: 50 as number | null, + }, + update: { auto_update: true, auto_download: false }, + logs: { enabled: false, capacity: 2000, capture_body: false, llm_only: true }, }; vi.mock("@src/config.js", () => ({ @@ -94,16 +102,10 @@ function makeApp() { describe("GET /admin/general-settings", () => { beforeEach(() => { vi.clearAllMocks(); - mockConfig.server.port = 8080; - mockConfig.server.proxy_api_key = null; - mockConfig.tls.proxy_url = null; - mockConfig.tls.force_http11 = false; - mockConfig.model.inject_desktop_context = false; - mockConfig.model.suppress_desktop_directives = true; - mockConfig.update.auto_update = true; + mockConfig.logs.llm_only = true; }); - it("returns current values", async () => { + it("returns current values including logs_llm_only", async () => { const app = makeApp(); const res = await app.request("/admin/general-settings"); expect(res.status).toBe(200); @@ -112,16 +114,14 @@ describe("GET /admin/general-settings", () => { port: 8080, proxy_url: null, force_http11: false, - inject_desktop_context: false, - suppress_desktop_directives: true, default_model: "gpt-5.3-codex", - default_reasoning_effort: null, refresh_enabled: true, - refresh_margin_seconds: 300, - refresh_concurrency: 2, - max_concurrent_per_account: 3, - request_interval_ms: 50, auto_update: true, + auto_download: false, + logs_enabled: false, + logs_capacity: 2000, + logs_capture_body: false, + logs_llm_only: true, }); }); }); @@ -129,124 +129,17 @@ describe("GET /admin/general-settings", () => { describe("POST /admin/general-settings", () => { beforeEach(() => { vi.clearAllMocks(); - mockConfig.server.port = 8080; - mockConfig.server.proxy_api_key = null; - mockConfig.tls.proxy_url = null; - mockConfig.tls.force_http11 = false; - mockConfig.model.inject_desktop_context = false; - mockConfig.model.suppress_desktop_directives = true; - mockConfig.update.auto_update = true; - }); - - it("changing port sets restart_required: true", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ port: 9090 }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.restart_required).toBe(true); - expect(mutateYaml).toHaveBeenCalledOnce(); - expect(reloadAllConfigs).toHaveBeenCalledOnce(); - }); - - it("changing proxy_url sets restart_required: false", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ proxy_url: "http://127.0.0.1:7890" }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.restart_required).toBe(false); - expect(mutateYaml).toHaveBeenCalledOnce(); - }); - - it("changing force_http11 sets restart_required: false", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ force_http11: true }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.restart_required).toBe(false); - }); - - it("rejects port out of range", async () => { - const app = makeApp(); - - const res1 = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ port: 0 }), - }); - expect(res1.status).toBe(400); - - const res2 = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ port: 70000 }), - }); - expect(res2.status).toBe(400); - }); - - it("rejects invalid proxy_url format", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ proxy_url: "not-a-url" }), - }); - expect(res.status).toBe(400); - }); - - it("changing inject_desktop_context sets restart_required: false", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ inject_desktop_context: true }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.restart_required).toBe(false); - expect(data.inject_desktop_context).toBe(false); // mockConfig unchanged - expect(mutateYaml).toHaveBeenCalledOnce(); - expect(reloadAllConfigs).toHaveBeenCalledOnce(); + mockConfig.logs.llm_only = true; }); - it("changing suppress_desktop_directives sets restart_required: false", async () => { + it("persists logs_llm_only without requiring restart", async () => { const app = makeApp(); const res = await app.request("/admin/general-settings", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ suppress_desktop_directives: false }), + body: JSON.stringify({ logs_llm_only: false }), }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.restart_required).toBe(false); - expect(data.suppress_desktop_directives).toBe(true); // mockConfig unchanged - expect(mutateYaml).toHaveBeenCalledOnce(); - expect(reloadAllConfigs).toHaveBeenCalledOnce(); - }); - it("changing auto_update persists to local.yaml", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ auto_update: false }), - }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); @@ -254,123 +147,4 @@ describe("POST /admin/general-settings", () => { expect(mutateYaml).toHaveBeenCalledOnce(); expect(reloadAllConfigs).toHaveBeenCalledOnce(); }); - - it("accepts valid max_concurrent_per_account", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ max_concurrent_per_account: 3 }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.restart_required).toBe(false); - expect(mutateYaml).toHaveBeenCalledOnce(); - }); - - it("accepts null max_concurrent_per_account (reset to default)", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ max_concurrent_per_account: null }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - }); - - it("rejects invalid max_concurrent_per_account", async () => { - const app = makeApp(); - - for (const bad of [0, -1, 1.5]) { - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ max_concurrent_per_account: bad }), - }); - expect(res.status).toBe(400); - } - }); - - it("accepts valid request_interval_ms", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request_interval_ms: 500 }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - }); - - it("accepts 0 request_interval_ms (disable)", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request_interval_ms: 0 }), - }); - expect(res.status).toBe(200); - }); - - it("rejects negative request_interval_ms", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request_interval_ms: -1 }), - }); - expect(res.status).toBe(400); - }); - - it("accepts null default_reasoning_effort to disable reasoning", async () => { - mockConfig.model.default_reasoning_effort = "medium"; - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ default_reasoning_effort: null }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(mutateYaml).toHaveBeenCalledOnce(); - }); - - it("rejects invalid default_reasoning_effort", async () => { - const app = makeApp(); - const res = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ default_reasoning_effort: "ultra" }), - }); - expect(res.status).toBe(400); - }); - - it("requires auth when proxy_api_key is set", async () => { - mockConfig.server.proxy_api_key = "my-secret"; - const app = makeApp(); - - // No auth -> 401 - const res1 = await app.request("/admin/general-settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ force_http11: true }), - }); - expect(res1.status).toBe(401); - - // With auth -> 200 - const res2 = await app.request("/admin/general-settings", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer my-secret", - }, - body: JSON.stringify({ force_http11: true }), - }); - expect(res2.status).toBe(200); - }); }); diff --git a/web/src/components/GeneralSettings.tsx b/web/src/components/GeneralSettings.tsx index 16d0914e..a7ef318e 100644 --- a/web/src/components/GeneralSettings.tsx +++ b/web/src/components/GeneralSettings.tsx @@ -25,6 +25,7 @@ export function GeneralSettings() { const [draftLogsEnabled, setDraftLogsEnabled] = useState(null); const [draftLogsCapacity, setDraftLogsCapacity] = useState(null); const [draftLogsCaptureBody, setDraftLogsCaptureBody] = useState(null); + const [draftLogsLlmOnly, setDraftLogsLlmOnly] = useState(null); const [collapsed, setCollapsed] = useState(true); const currentPort = gs.data?.port ?? 8080; @@ -44,6 +45,8 @@ export function GeneralSettings() { const currentLogsEnabled = gs.data?.logs_enabled ?? false; const currentLogsCapacity = gs.data?.logs_capacity ?? 2000; const currentLogsCaptureBody = gs.data?.logs_capture_body ?? false; + const currentLogsLlmOnly = gs.data?.logs_llm_only ?? true; + const displayPort = draftPort ?? String(currentPort); const displayProxyUrl = draftProxyUrl ?? currentProxyUrl; @@ -62,6 +65,7 @@ export function GeneralSettings() { const displayLogsEnabled = draftLogsEnabled ?? currentLogsEnabled; const displayLogsCapacity = draftLogsCapacity ?? String(currentLogsCapacity); const displayLogsCaptureBody = draftLogsCaptureBody ?? currentLogsCaptureBody; + const displayLogsLlmOnly = draftLogsLlmOnly ?? currentLogsLlmOnly; const isDirty = draftPort !== null || @@ -80,7 +84,8 @@ export function GeneralSettings() { draftAutoDownload !== null || draftLogsEnabled !== null || draftLogsCapacity !== null || - draftLogsCaptureBody !== null; + draftLogsCaptureBody !== null || + draftLogsLlmOnly !== null; const handleSave = useCallback(async () => { const patch: Record = {}; @@ -165,6 +170,10 @@ export function GeneralSettings() { patch.logs_capture_body = draftLogsCaptureBody; } + if (draftLogsLlmOnly !== null) { + patch.logs_llm_only = draftLogsLlmOnly; + } + await gs.save(patch); setDraftPort(null); setDraftProxyUrl(null); @@ -183,7 +192,8 @@ export function GeneralSettings() { setDraftLogsEnabled(null); setDraftLogsCapacity(null); setDraftLogsCaptureBody(null); - }, [draftPort, draftProxyUrl, draftForceHttp11, draftInjectContext, draftSuppressDirectives, draftDefaultModel, draftReasoningEffort, draftRefreshEnabled, draftRefreshMargin, draftRefreshConcurrency, draftMaxConcurrent, draftRequestInterval, draftAutoUpdate, draftAutoDownload, draftLogsEnabled, draftLogsCapacity, draftLogsCaptureBody, gs]); + setDraftLogsLlmOnly(null); + }, [draftPort, draftProxyUrl, draftForceHttp11, draftInjectContext, draftSuppressDirectives, draftDefaultModel, draftReasoningEffort, draftRefreshEnabled, draftRefreshMargin, draftRefreshConcurrency, draftMaxConcurrent, draftRequestInterval, draftAutoUpdate, draftAutoDownload, draftLogsEnabled, draftLogsCapacity, draftLogsCaptureBody, draftLogsLlmOnly, gs]); const inputCls = "w-full px-3 py-2 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-700 dark:text-text-main outline-none focus:ring-1 focus:ring-primary"; @@ -299,6 +309,22 @@ export function GeneralSettings() {

{t("logsCaptureBodyHint")}

+
+
+ setDraftLogsLlmOnly((e.target as HTMLInputElement).checked)} + class="w-4 h-4 rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer" + /> + +
+

{t("logsLlmOnlyHint")}

+
+ {/* Server Port */}
+ + ({ useT: vi.fn(), })); +const mockSettings = vi.hoisted(() => ({ + useSettings: vi.fn(() => ({ apiKey: null })), +})); + +const mockGeneralSettings = vi.hoisted(() => ({ + useGeneralSettings: vi.fn(), +})); + vi.mock("../../../shared/hooks/use-logs", () => ({ useLogs: mockLogs.useLogs, })); +vi.mock("../../../shared/hooks/use-settings", () => ({ + useSettings: mockSettings.useSettings, +})); + +vi.mock("../../../shared/hooks/use-general-settings", () => ({ + useGeneralSettings: mockGeneralSettings.useGeneralSettings, +})); + vi.mock("../../../shared/i18n/context", () => ({ useT: () => mockT.useT(), })); import { LogsPage } from "../LogsPage"; +function makeGeneralSettings(overrides: Record = {}) { + return { + data: { logs_llm_only: true }, + saving: false, + save: vi.fn(), + ...overrides, + }; +} + function makeLogsState(overrides: Partial> = {}) { return { records: [ @@ -61,6 +86,7 @@ describe("LogsPage", () => { return key; }); mockLogs.useLogs.mockReturnValue(makeLogsState({ nextPage, hasNext: true })); + mockGeneralSettings.useGeneralSettings.mockReturnValue(makeGeneralSettings()); render(); @@ -74,6 +100,7 @@ describe("LogsPage", () => { if (key === "logsCount") return `${vars?.count ?? 0} logs`; return key; }); + mockGeneralSettings.useGeneralSettings.mockReturnValue(makeGeneralSettings()); mockLogs.useLogs.mockReturnValue(makeLogsState({ selected: { id: "1", path: "/v1/messages" } })); const { rerender } = render(); @@ -83,4 +110,19 @@ describe("LogsPage", () => { rerender(); expect(screen.getByText("logsSelectHint")).toBeTruthy(); }); + + it("renders and toggles the logs mode button", () => { + const save = vi.fn(); + mockT.useT.mockImplementation(() => (key: string, vars?: Record) => { + if (key === "logsCount") return `${vars?.count ?? 0} logs`; + return key; + }); + mockLogs.useLogs.mockReturnValue(makeLogsState()); + mockGeneralSettings.useGeneralSettings.mockReturnValue(makeGeneralSettings({ save })); + + render(); + + fireEvent.click(screen.getByText("logsModeLlmOnlyToggle")); + expect(save).toHaveBeenCalledWith({ logs_llm_only: false }); + }); }); From cf56c7e76661b75753dfc02e429941bb690a56bf Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Fri, 17 Apr 2026 12:59:27 +0800 Subject: [PATCH 15/17] feat: separate logs settings drawer and sync enabled state Move log-related controls into a dedicated settings drawer and keep the saved logs enabled setting in sync with the logs page toggle while leaving pause as an independent runtime state. --- shared/i18n/translations.ts | 4 + src/routes/admin/logs.ts | 11 ++ src/routes/admin/settings.ts | 5 + tests/unit/routes/general-settings.test.ts | 20 +++ tests/unit/routes/logs.test.ts | 40 +++++ web/src/components/GeneralSettings.tsx | 106 +------------ web/src/components/LogsSettings.tsx | 168 +++++++++++++++++++++ web/src/components/SettingsTab.tsx | 2 + 8 files changed, 252 insertions(+), 104 deletions(-) create mode 100644 web/src/components/LogsSettings.tsx diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index b6c1723d..062dac8d 100644 --- a/shared/i18n/translations.ts +++ b/shared/i18n/translations.ts @@ -288,6 +288,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)", @@ -553,6 +555,8 @@ export const translations = { apiKeys: "API Keys", usageStats: "用量统计", logs: "日志", + logsSettings: "日志设置", + logsEnable: "启用日志", logsEnabled: "已启用", logsEnabledHint: "开启请求日志与历史记录。", logsDisabled: "已禁用", diff --git a/src/routes/admin/logs.ts b/src/routes/admin/logs.ts index 6df78715..a6bd3fe6 100644 --- a/src/routes/admin/logs.ts +++ b/src/routes/admin/logs.ts @@ -1,6 +1,8 @@ import { Hono } from "hono"; import { z } from "zod"; +import { getLocalConfigPath, reloadAllConfigs } from "../../config.js"; import { logStore, type LogDirection } from "../../logs/store.js"; +import { mutateYaml } from "../../utils/yaml-mutate.js"; const ListLogsQuerySchema = z.object({ limit: z.preprocess((value) => value === undefined ? undefined : Number(value), z.number().int().min(1).max(200).optional()), @@ -44,6 +46,15 @@ export function createLogRoutes(): Hono { const body = await c.req.json().catch(() => ({} as Record)); const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined; const paused = typeof body.paused === "boolean" ? body.paused : undefined; + + if (enabled !== undefined) { + mutateYaml(getLocalConfigPath(), (data) => { + if (!data.logs) data.logs = {}; + (data.logs as Record).enabled = enabled; + }); + reloadAllConfigs(); + } + return c.json(logStore.setState({ enabled, paused })); }); diff --git a/src/routes/admin/settings.ts b/src/routes/admin/settings.ts index 1344f12d..3956079a 100644 --- a/src/routes/admin/settings.ts +++ b/src/routes/admin/settings.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import { getConnInfo } from "@hono/node-server/conninfo"; import { getConfig, getLocalConfigPath, reloadAllConfigs, ROTATION_STRATEGIES } from "../../config.js"; +import { logStore } from "../../logs/store.js"; import { mutateYaml } from "../../utils/yaml-mutate.js"; import { isLocalhostRequest } from "../../utils/is-localhost.js"; @@ -292,6 +293,10 @@ export function createSettingsRoutes(): Hono { }); reloadAllConfigs(); + if (body.logs_enabled !== undefined) { + logStore.setState({ enabled: body.logs_enabled }); + } + const updated = getConfig(); const restartRequired = (body.port !== undefined && body.port !== oldPort) || diff --git a/tests/unit/routes/general-settings.test.ts b/tests/unit/routes/general-settings.test.ts index 90bc8078..5feb6a72 100644 --- a/tests/unit/routes/general-settings.test.ts +++ b/tests/unit/routes/general-settings.test.ts @@ -46,10 +46,18 @@ vi.mock("@src/paths.js", () => ({ isEmbedded: vi.fn(() => false), })); +const mockLogStore = vi.hoisted(() => ({ + setState: vi.fn(), +})); + vi.mock("@src/utils/yaml-mutate.js", () => ({ mutateYaml: vi.fn(), })); +vi.mock("@src/logs/store.js", () => ({ + logStore: mockLogStore, +})); + vi.mock("@src/tls/transport.js", () => ({ getTransport: vi.fn(), getTransportInfo: vi.fn(() => ({})), @@ -147,4 +155,16 @@ describe("POST /admin/general-settings", () => { expect(mutateYaml).toHaveBeenCalledOnce(); expect(reloadAllConfigs).toHaveBeenCalledOnce(); }); + + it("syncs log store when logs_enabled changes", async () => { + const app = makeApp(); + const res = await app.request("/admin/general-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ logs_enabled: true }), + }); + + expect(res.status).toBe(200); + expect(mockLogStore.setState).toHaveBeenCalledWith({ enabled: true }); + }); }); diff --git a/tests/unit/routes/logs.test.ts b/tests/unit/routes/logs.test.ts index 7baab09a..11836da0 100644 --- a/tests/unit/routes/logs.test.ts +++ b/tests/unit/routes/logs.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Hono } from "hono"; +const mockConfig = vi.hoisted(() => ({ + getLocalConfigPath: vi.fn(() => "/tmp/test/local.yaml"), + reloadAllConfigs: vi.fn(), +})); + +const mockYaml = vi.hoisted(() => ({ + mutateYaml: vi.fn(), +})); + const store = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), @@ -8,6 +17,15 @@ const store = vi.hoisted(() => ({ setState: vi.fn(), })); +vi.mock("../../config.js", () => ({ + getLocalConfigPath: mockConfig.getLocalConfigPath, + reloadAllConfigs: mockConfig.reloadAllConfigs, +})); + +vi.mock("../../utils/yaml-mutate.js", () => ({ + mutateYaml: mockYaml.mutateYaml, +})); + vi.mock("../../logs/store.js", () => ({ logStore: store, })); @@ -20,6 +38,10 @@ describe("log routes", () => { store.get.mockReset(); store.clear.mockReset(); store.setState.mockReset(); + mockConfig.getLocalConfigPath.mockReset(); + mockConfig.getLocalConfigPath.mockReturnValue("/tmp/test/local.yaml"); + mockConfig.reloadAllConfigs.mockReset(); + mockYaml.mutateYaml.mockReset(); }); it("returns paginated logs", async () => { @@ -96,4 +118,22 @@ describe("log routes", () => { expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ id: "abc", path: "/health" }); }); + + it("persists enabled state changes while keeping paused in memory", async () => { + store.setState.mockReturnValue({ enabled: false, paused: true, dropped: 0, size: 0, capacity: 2000 }); + + const app = new Hono(); + app.route("/", createLogRoutes()); + + const res = await app.request("/admin/logs/state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: false, paused: true }), + }); + + expect(res.status).toBe(200); + expect(mockYaml.mutateYaml).toHaveBeenCalledOnce(); + expect(mockConfig.reloadAllConfigs).toHaveBeenCalledOnce(); + expect(store.setState).toHaveBeenCalledWith({ enabled: false, paused: true }); + }); }); diff --git a/web/src/components/GeneralSettings.tsx b/web/src/components/GeneralSettings.tsx index a7ef318e..25886edf 100644 --- a/web/src/components/GeneralSettings.tsx +++ b/web/src/components/GeneralSettings.tsx @@ -22,10 +22,6 @@ export function GeneralSettings() { const [draftRequestInterval, setDraftRequestInterval] = useState(null); const [draftAutoUpdate, setDraftAutoUpdate] = useState(null); const [draftAutoDownload, setDraftAutoDownload] = useState(null); - const [draftLogsEnabled, setDraftLogsEnabled] = useState(null); - const [draftLogsCapacity, setDraftLogsCapacity] = useState(null); - const [draftLogsCaptureBody, setDraftLogsCaptureBody] = useState(null); - const [draftLogsLlmOnly, setDraftLogsLlmOnly] = useState(null); const [collapsed, setCollapsed] = useState(true); const currentPort = gs.data?.port ?? 8080; @@ -42,11 +38,6 @@ export function GeneralSettings() { const currentRequestInterval = gs.data?.request_interval_ms ?? 50; const currentAutoUpdate = gs.data?.auto_update ?? true; const currentAutoDownload = gs.data?.auto_download ?? false; - const currentLogsEnabled = gs.data?.logs_enabled ?? false; - const currentLogsCapacity = gs.data?.logs_capacity ?? 2000; - const currentLogsCaptureBody = gs.data?.logs_capture_body ?? false; - const currentLogsLlmOnly = gs.data?.logs_llm_only ?? true; - const displayPort = draftPort ?? String(currentPort); const displayProxyUrl = draftProxyUrl ?? currentProxyUrl; @@ -62,10 +53,6 @@ export function GeneralSettings() { const displayRequestInterval = draftRequestInterval ?? String(currentRequestInterval); const displayAutoUpdate = draftAutoUpdate ?? currentAutoUpdate; const displayAutoDownload = draftAutoDownload ?? currentAutoDownload; - const displayLogsEnabled = draftLogsEnabled ?? currentLogsEnabled; - const displayLogsCapacity = draftLogsCapacity ?? String(currentLogsCapacity); - const displayLogsCaptureBody = draftLogsCaptureBody ?? currentLogsCaptureBody; - const displayLogsLlmOnly = draftLogsLlmOnly ?? currentLogsLlmOnly; const isDirty = draftPort !== null || @@ -81,11 +68,7 @@ export function GeneralSettings() { draftMaxConcurrent !== null || draftRequestInterval !== null || draftAutoUpdate !== null || - draftAutoDownload !== null || - draftLogsEnabled !== null || - draftLogsCapacity !== null || - draftLogsCaptureBody !== null || - draftLogsLlmOnly !== null; + draftAutoDownload !== null; const handleSave = useCallback(async () => { const patch: Record = {}; @@ -156,24 +139,6 @@ export function GeneralSettings() { patch.auto_download = draftAutoDownload; } - if (draftLogsEnabled !== null) { - patch.logs_enabled = draftLogsEnabled; - } - - if (draftLogsCapacity !== null) { - const val = parseInt(draftLogsCapacity, 10); - if (isNaN(val) || val < 1) return; - patch.logs_capacity = val; - } - - if (draftLogsCaptureBody !== null) { - patch.logs_capture_body = draftLogsCaptureBody; - } - - if (draftLogsLlmOnly !== null) { - patch.logs_llm_only = draftLogsLlmOnly; - } - await gs.save(patch); setDraftPort(null); setDraftProxyUrl(null); @@ -189,11 +154,7 @@ export function GeneralSettings() { setDraftRequestInterval(null); setDraftAutoUpdate(null); setDraftAutoDownload(null); - setDraftLogsEnabled(null); - setDraftLogsCapacity(null); - setDraftLogsCaptureBody(null); - setDraftLogsLlmOnly(null); - }, [draftPort, draftProxyUrl, draftForceHttp11, draftInjectContext, draftSuppressDirectives, draftDefaultModel, draftReasoningEffort, draftRefreshEnabled, draftRefreshMargin, draftRefreshConcurrency, draftMaxConcurrent, draftRequestInterval, draftAutoUpdate, draftAutoDownload, draftLogsEnabled, draftLogsCapacity, draftLogsCaptureBody, draftLogsLlmOnly, gs]); + }, [draftPort, draftProxyUrl, draftForceHttp11, draftInjectContext, draftSuppressDirectives, draftDefaultModel, draftReasoningEffort, draftRefreshEnabled, draftRefreshMargin, draftRefreshConcurrency, draftMaxConcurrent, draftRequestInterval, draftAutoUpdate, draftAutoDownload, gs]); const inputCls = "w-full px-3 py-2 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-700 dark:text-text-main outline-none focus:ring-1 focus:ring-primary"; @@ -262,69 +223,6 @@ export function GeneralSettings() {

{t("generalSettingsAutoDownloadHint")}

- {/* Logs */} -
-
- setDraftLogsEnabled((e.target as HTMLInputElement).checked)} - class="w-4 h-4 rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer" - /> - -
-

{t("logsEnabledHint")}

-
- -
- -

{t("logsCapacityHint")}

- setDraftLogsCapacity((e.target as HTMLInputElement).value)} - /> -
- -
-
- setDraftLogsCaptureBody((e.target as HTMLInputElement).checked)} - class="w-4 h-4 rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer" - /> - -
-

{t("logsCaptureBodyHint")}

-
- -
-
- setDraftLogsLlmOnly((e.target as HTMLInputElement).checked)} - class="w-4 h-4 rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer" - /> - -
-

{t("logsLlmOnlyHint")}

-
- {/* Server Port */}
diff --git a/web/src/pages/__tests__/logs.test.ts b/web/src/pages/__tests__/logs.test.ts index 8b473e30..99c91f5c 100644 --- a/web/src/pages/__tests__/logs.test.ts +++ b/web/src/pages/__tests__/logs.test.ts @@ -90,6 +90,7 @@ describe("LogsPage", () => { render(); + expect(screen.getByText("1 logs")).toBeTruthy(); expect(screen.getByText("1 total · 1-1")).toBeTruthy(); fireEvent.click(screen.getByText("Next")); expect(nextPage).toHaveBeenCalledTimes(1); @@ -111,6 +112,34 @@ describe("LogsPage", () => { expect(screen.getByText("logsSelectHint")).toBeTruthy(); }); + it("renders zero latency as 0ms", () => { + mockT.useT.mockImplementation(() => (key: string, vars?: Record) => { + if (key === "logsCount") return `${vars?.count ?? 0} logs`; + return key; + }); + mockLogs.useLogs.mockReturnValue( + makeLogsState({ + records: [ + { + id: "1", + requestId: "r1", + direction: "ingress", + ts: "2026-04-15T00:00:01.000Z", + method: "GET", + path: "/v1/models", + status: 200, + latencyMs: 0, + }, + ], + }), + ); + mockGeneralSettings.useGeneralSettings.mockReturnValue(makeGeneralSettings()); + + render(); + + expect(screen.getByText("0ms")).toBeTruthy(); + }); + it("renders and toggles the logs mode button", () => { const save = vi.fn(); mockT.useT.mockImplementation(() => (key: string, vars?: Record) => { From f5c3b51e53b8a50dc56938b4244e951bcb812934 Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Fri, 17 Apr 2026 20:17:59 +0800 Subject: [PATCH 17/17] Localize log pagination and tighten packaging tests --- packages/electron/__tests__/builder-config.test.ts | 8 +++++++- shared/i18n/translations.ts | 6 ++++++ tests/unit/routes/logs.test.ts | 8 ++++---- web/src/pages/LogsPage.tsx | 6 +++--- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/electron/__tests__/builder-config.test.ts b/packages/electron/__tests__/builder-config.test.ts index f5943cbb..f1e05e19 100644 --- a/packages/electron/__tests__/builder-config.test.ts +++ b/packages/electron/__tests__/builder-config.test.ts @@ -72,7 +72,7 @@ describe("electron-builder.yml", () => { it("root source directories for prepare-pack actually exist", () => { // prepare-pack.mjs copies these from root before packing - const requiredDirs = ["config", "public"]; + const requiredDirs = ["config", "public", "bin"]; for (const dir of requiredDirs) { const rootPath = resolve(ROOT_DIR, dir); expect( @@ -87,6 +87,12 @@ describe("electron-builder.yml", () => { (r) => r.to === "bin/" || r.to === "bin", ); expect(binResource).toBeDefined(); + // bin/ is copied from root by prepare-pack before packing + const rootBin = resolve(ROOT_DIR, "bin"); + expect( + existsSync(rootBin), + `Root bin/ directory should exist at ${rootBin}`, + ).toBe(true); }); it("extraResources native/ maps to correct root directory", () => { diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index 062dac8d..93c51d0e 100644 --- a/shared/i18n/translations.ts +++ b/shared/i18n/translations.ts @@ -242,6 +242,9 @@ export const translations = { 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", @@ -584,6 +587,9 @@ export const translations = { logsEmpty: "暂无日志", logsDetails: "详情", logsSelectHint: "选择一条日志查看详情", + logsPrev: "上一页", + logsNext: "下一页", + logsPageSummary: "共 {total} 条 · {range}", totalInputTokens: "输入 Token", totalOutputTokens: "输出 Token", totalRequestCount: "总请求数", diff --git a/tests/unit/routes/logs.test.ts b/tests/unit/routes/logs.test.ts index a76c1d2d..5b18f492 100644 --- a/tests/unit/routes/logs.test.ts +++ b/tests/unit/routes/logs.test.ts @@ -17,20 +17,20 @@ const store = vi.hoisted(() => ({ setState: vi.fn(), })); -vi.mock("../../config.js", () => ({ +vi.mock("@src/config.js", () => ({ getLocalConfigPath: mockConfig.getLocalConfigPath, reloadAllConfigs: mockConfig.reloadAllConfigs, })); -vi.mock("../../utils/yaml-mutate.js", () => ({ +vi.mock("@src/utils/yaml-mutate.js", () => ({ mutateYaml: mockYaml.mutateYaml, })); -vi.mock("../../logs/store.js", () => ({ +vi.mock("@src/logs/store.js", () => ({ logStore: store, })); -import { createLogRoutes } from "../admin/logs.js"; +import { createLogRoutes } from "@src/routes/admin/logs.js"; describe("log routes", () => { beforeEach(() => { diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index a0cee923..d18e6b7f 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -122,15 +122,15 @@ export function LogsPage({ embedded = false }: { embedded?: boolean }) { disabled={!logs.hasPrev} onClick={logs.prevPage} > - Prev + {t("logsPrev")} - {logs.total} total · {pageInfo} + {t("logsPageSummary", { total: logs.total, range: pageInfo })}