diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index b56c3e9..b99baa6 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -680,6 +680,60 @@ func TestApplyAndRestore(t *testing.T) { } } +func TestResponsesRequestsAreLogged(t *testing.T) { + env := newCodexTestEnv(t) + resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", false), env.store.Config().RelayToken) + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + status := env.get(t, "/api/status") + stats := status["stats"].(map[string]any) + if stats["requests_total"].(float64) != 1 { + t.Errorf("requests_total = %v, want 1", stats["requests_total"]) + } + logs := env.get(t, "/api/logs")["items"].([]any) + if len(logs) != 1 { + t.Fatalf("request logs = %v, want exactly one Responses request", logs) + } + if logs[0].(map[string]any)["path"] != "/v1/responses" { + t.Errorf("logged path = %v, want /v1/responses", logs[0].(map[string]any)["path"]) + } + if logs[0].(map[string]any)["app"] != "Codex" || logs[0].(map[string]any)["provider"] != "codex-up" { + t.Errorf("log app/provider = %v/%v, want Codex/codex-up", logs[0].(map[string]any)["app"], logs[0].(map[string]any)["provider"]) + } +} + +func TestManagementRequestsAreExcludedFromLogsAndStats(t *testing.T) { + env := newTestEnv(t) + for _, path := range []string{"/api/status", "/api/logs", "/api/providers?target=claude", "/api/settings"} { + resp, err := http.Get(env.gateway.URL + path) + if err != nil { + t.Fatalf("get %s: %v", path, err) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("GET %s status = %d, want 200", path, resp.StatusCode) + } + } + + resp := env.post(t, "/v1/messages/count_tokens", messagesBody("m", false), env.store.Config().RelayToken) + resp.Body.Close() + + status := env.get(t, "/api/status") + stats := status["stats"].(map[string]any) + if stats["requests_total"].(float64) != 0 { + t.Errorf("requests_total = %v, want 0 after management requests", stats["requests_total"]) + } + logs := env.get(t, "/api/logs")["items"].([]any) + if len(logs) != 0 { + t.Errorf("request logs = %v, want no management requests", logs) + } +} + func TestStatusAndLogs(t *testing.T) { env := newTestEnv(t) resp := env.post(t, "/v1/messages", messagesBody("m", false), env.store.Config().RelayToken) @@ -701,8 +755,8 @@ func TestStatusAndLogs(t *testing.T) { t.Errorf("status = %v", status) } stats := status["stats"].(map[string]any) - if stats["requests_total"].(float64) < 1 { - t.Errorf("stats = %v", stats) + if stats["requests_total"].(float64) != 1 { + t.Errorf("stats = %v, want exactly the one model request", stats) } active := status["active_claude_provider"].(map[string]any) if active["name"] != "test" { @@ -725,6 +779,9 @@ func TestStatusAndLogs(t *testing.T) { if messageLog["model"] != "ds-chat" { t.Errorf("log model = %v, want the resolved model", messageLog["model"]) } + if messageLog["app"] != "Claude Code" || messageLog["provider"] != "test" { + t.Errorf("log app/provider = %v/%v, want Claude Code/test", messageLog["app"], messageLog["provider"]) + } if messageLog["status"].(float64) != 200 { t.Errorf("log status = %v", messageLog["status"]) } diff --git a/internal/gateway/messages.go b/internal/gateway/messages.go index ec51fb5..f5e6f67 100644 --- a/internal/gateway/messages.go +++ b/internal/gateway/messages.go @@ -34,6 +34,7 @@ func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { rec := recordFrom(r) rec.update(func(e *LogEntry) { + e.Provider = provider.Name e.Model = model e.InputTokens = anthropic.EstimateTokens(&req) }) diff --git a/internal/gateway/responses.go b/internal/gateway/responses.go index 2eb62bc..538efc0 100644 --- a/internal/gateway/responses.go +++ b/internal/gateway/responses.go @@ -38,6 +38,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { rec := recordFrom(r) if provider.Protocol == config.ProtocolOpenAIResponses { rec.update(func(e *LogEntry) { + e.Provider = provider.Name e.Model = model e.InputTokens = (len(body) + 3) / 4 }) @@ -57,6 +58,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { } rec.update(func(e *LogEntry) { + e.Provider = provider.Name e.Model = model e.InputTokens = responses.EstimateTokens(chatReq) }) diff --git a/internal/gateway/server.go b/internal/gateway/server.go index ec67e98..c50165e 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -35,6 +35,8 @@ type LogEntry struct { Time time.Time `json:"time"` Method string `json:"method"` Path string `json:"path"` + App string `json:"app,omitempty"` + Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` Status int `json:"status"` DurationMS int64 `json:"duration_ms"` @@ -71,9 +73,9 @@ func (s *Server) Port() int { return s.port } // Handler returns the full HTTP handler. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() - mux.HandleFunc("POST /v1/messages", s.withAuth(s.handleMessages)) + mux.Handle("POST /v1/messages", s.withLogging(s.withAuth(s.handleMessages))) mux.HandleFunc("POST /v1/messages/count_tokens", s.withAuth(s.handleCountTokens)) - mux.HandleFunc("POST /v1/responses", s.withAuth(s.handleResponses)) + mux.Handle("POST /v1/responses", s.withLogging(s.withAuth(s.handleResponses))) mux.HandleFunc("GET /api/status", s.handleStatus) mux.HandleFunc("POST /api/fetch-models", s.handleFetchModels) mux.HandleFunc("GET /api/providers", s.handleListProviders) @@ -86,7 +88,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/settings", s.handleGetSettings) mux.HandleFunc("PUT /api/settings", s.handleUpdateSettings) mux.HandleFunc("GET /api/logs", s.handleLogs) - return s.withLogging(mux) + return mux } // withAuth protects the Anthropic-compatible endpoints with the local @@ -139,6 +141,17 @@ func (rec *record) update(fn func(*LogEntry)) { fn(&rec.entry) } +func requestApp(path string) string { + switch path { + case "/v1/messages": + return "Claude Code" + case "/v1/responses": + return "Codex" + default: + return "" + } +} + func (s *Server) withLogging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() @@ -146,6 +159,7 @@ func (s *Server) withLogging(next http.Handler) http.Handler { Time: start, Method: r.Method, Path: r.URL.Path, + App: requestApp(r.URL.Path), }} ww := &statusWriter{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(ww, r.WithContext(context.WithValue(r.Context(), recordKey{}, rec))) diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 419e060..5eaa88f 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -46,13 +46,39 @@ export const zhCN = { tokensIn: "输入 Token", tokensOut: "输出 Token", recentRequests: "最近请求", + tabLogs: "请求日志", + tabProviders: "供应商", + tabModels: "模型", + tabSettings: "设置", + usageTabs: "用量页面标签", + usageWindowHint: "汇总范围:内存中最近 100 条模型请求,不是历史总量", + summaryRequests: "请求数", + summaryInput: "输入 Token", + summaryOutput: "输出 Token", + summaryTokens: "Token", + summaryFailed: "失败", + summarySuccessRate: "成功率", + summaryCost: "成本", + displaySettings: "显示设置", + logColumns: "请求日志列", + resetColumns: "恢复默认列", + dataLimits: "数据范围", + dataLimitsDescription: "CrossRelay 当前只保留最近 100 条模型请求于内存,重启后清空;成本、缓存命中和首字延迟暂未采集。", + configSettings: "网关设置", + openConfigSettings: "打开网关设置", quickApply: "写入 Claude 配置", restoreDone: "已还原为官方配置", tableTime: "时间", + tableApp: "应用", + tableProvider: "供应商", tableModel: "模型", - tableStatus: "状态", - tableDuration: "耗时", - tableTokens: "Token", + tableInput: "新增输入", + tableOutput: "输出", + tableCacheHit: "缓存命中", + tableCost: "成本", + tableUnpriced: "未定价", + tableSpeed: "速度", + tableFirstToken: "首字", emptyLogs: "暂无请求记录", statsTitle: "统计", }, @@ -189,13 +215,39 @@ export const enUS = { tokensIn: "Input tokens", tokensOut: "Output tokens", recentRequests: "Recent requests", + tabLogs: "Request logs", + tabProviders: "Providers", + tabModels: "Models", + tabSettings: "Settings", + usageTabs: "Usage page tabs", + usageWindowHint: "Scope: the latest 100 model requests held in memory, not all-time history", + summaryRequests: "Requests", + summaryInput: "Input tokens", + summaryOutput: "Output tokens", + summaryTokens: "Tokens", + summaryFailed: "Failed", + summarySuccessRate: "Success rate", + summaryCost: "Cost", + displaySettings: "Display settings", + logColumns: "Request log columns", + resetColumns: "Restore default columns", + dataLimits: "Data scope", + dataLimitsDescription: "CrossRelay currently keeps only the latest 100 model requests in memory; they are cleared on restart. Cost, cache hits, and first-token latency are not collected yet.", + configSettings: "Gateway settings", + openConfigSettings: "Open gateway settings", quickApply: "Write to Claude config", restoreDone: "Official config restored", tableTime: "Time", + tableApp: "App", + tableProvider: "Provider", tableModel: "Model", - tableStatus: "Status", - tableDuration: "Duration", - tableTokens: "Tokens", + tableInput: "Fresh Input", + tableOutput: "Output", + tableCacheHit: "Cache Hit", + tableCost: "Cost", + tableUnpriced: "Unpriced", + tableSpeed: "Speed", + tableFirstToken: "First token", emptyLogs: "No requests yet", statsTitle: "Stats", }, diff --git a/src/lib/types.ts b/src/lib/types.ts index bfb49a4..1fc998d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -66,6 +66,8 @@ export interface LogEntry { time: string; method: string; path: string; + app?: string; + provider?: string; model?: string; status: number; duration_ms: number; diff --git a/src/pages/Dashboard.test.tsx b/src/pages/Dashboard.test.tsx index ebd18e2..c299e34 100644 --- a/src/pages/Dashboard.test.tsx +++ b/src/pages/Dashboard.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "@/test/utils"; const getStatus = vi.fn(); @@ -26,6 +27,7 @@ import Dashboard from "./Dashboard"; beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); getStatus.mockResolvedValue({ running: true, version: "0.1.0", @@ -43,6 +45,8 @@ beforeEach(() => { time: "2026-10-18T08:00:00Z", method: "POST", path: "/v1/messages", + app: "Claude Code", + provider: "DeepSeek", model: "deepseek-chat", status: 200, duration_ms: 820, @@ -54,14 +58,49 @@ beforeEach(() => { }); describe("Dashboard", () => { + it("switches between request logs, provider stats, model stats and settings", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + expect(await screen.findByRole("tab", { name: "请求日志" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("columnheader", { name: "新增输入" })).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "供应商" })); + expect(screen.getByRole("columnheader", { name: "请求数" })).toBeInTheDocument(); + expect(screen.getAllByText("DeepSeek").length).toBeGreaterThanOrEqual(2); + expect(screen.queryByRole("columnheader", { name: "新增输入" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "模型" })); + expect(screen.getByRole("columnheader", { name: "模型" })).toBeInTheDocument(); + expect(screen.getByText("deepseek-chat")).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "设置" })); + expect(screen.getByText(/最近 100 条模型请求/)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "打开网关设置" })).toHaveAttribute("href", "/settings"); + }); + + it("persists request log column preferences", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole("tab", { name: "设置" })); + await user.click(screen.getByRole("checkbox", { name: "速度" })); + expect(localStorage.getItem("crossrelay.dashboard.logColumns")).not.toContain("speed"); + await user.click(screen.getByRole("button", { name: "恢复默认列" })); + expect(localStorage.getItem("crossrelay.dashboard.logColumns")).toBeNull(); + }); + it("renders gateway status, active provider and stats", async () => { renderWithProviders(); expect(await screen.findByText("运行中")).toBeInTheDocument(); expect(screen.getByText(/52917/)).toBeInTheDocument(); - expect(screen.getByText("DeepSeek")).toBeInTheDocument(); + expect(screen.getAllByText("DeepSeek").length).toBeGreaterThanOrEqual(2); expect(screen.getByText("7")).toBeInTheDocument(); // requests total expect(screen.getByText("deepseek-chat")).toBeInTheDocument(); // log model - // 200 shows up both as output tokens and as the log row status. - expect(screen.getAllByText("200").length).toBeGreaterThanOrEqual(2); + for (const header of ["时间", "应用", "供应商", "模型", "新增输入", "输出", "缓存命中", "成本", "速度", "首字"]) { + expect(screen.getByRole("columnheader", { name: header })).toBeInTheDocument(); + } + expect(screen.getByText("Claude Code")).toBeInTheDocument(); + expect(screen.getAllByText("DeepSeek").length).toBeGreaterThanOrEqual(2); + expect(screen.getByText("≈14.6 tok/s")).toBeInTheDocument(); }); }); diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 30d19c5..d297f16 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,3 +1,4 @@ +import { Fragment, useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { RotateCcw, Zap } from "lucide-react"; import { useRestore, useStatus, useLogs } from "@/lib/queries"; @@ -6,6 +7,77 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table"; +import type { LogEntry } from "@/lib/types"; + +type DashboardTab = "logs" | "providers" | "models" | "settings"; +type LogColumnId = "time" | "app" | "provider" | "model" | "input" | "output" | "cacheHit" | "cost" | "speed" | "firstToken"; +type LogColumn = { id: LogColumnId; label: string; defaultVisible: boolean }; +type UsageGroup = { + key: string; + label: string; + app: string; + requests: number; + failed: number; + inputTokens: number; + outputTokens: number; + durationMS: number; +}; + +function groupRequests(entries: LogEntry[], groupBy: "provider" | "model"): UsageGroup[] { + const groups = new Map(); + for (const entry of entries) { + const app = entry.app ?? "—"; + const label = groupBy === "provider" ? entry.provider ?? "—" : entry.model ?? "—"; + const key = `${app}\\0${label}`; + const row = groups.get(key) ?? { + key, + label, + app, + requests: 0, + failed: 0, + inputTokens: 0, + outputTokens: 0, + durationMS: 0, + }; + row.requests += 1; + if (entry.status < 200 || entry.status >= 300) row.failed += 1; + row.inputTokens += entry.input_tokens ?? 0; + row.outputTokens += entry.output_tokens ?? 0; + row.durationMS += entry.duration_ms; + groups.set(key, row); + } + return [...groups.values()].sort((a, b) => b.requests - a.requests); +} + +const LOG_COLUMNS: LogColumn[] = [ + { id: "time", label: "dashboard.tableTime", defaultVisible: true }, + { id: "app", label: "dashboard.tableApp", defaultVisible: true }, + { id: "provider", label: "dashboard.tableProvider", defaultVisible: true }, + { id: "model", label: "dashboard.tableModel", defaultVisible: true }, + { id: "input", label: "dashboard.tableInput", defaultVisible: true }, + { id: "output", label: "dashboard.tableOutput", defaultVisible: true }, + { id: "cacheHit", label: "dashboard.tableCacheHit", defaultVisible: true }, + { id: "cost", label: "dashboard.tableCost", defaultVisible: true }, + { id: "speed", label: "dashboard.tableSpeed", defaultVisible: true }, + { id: "firstToken", label: "dashboard.tableFirstToken", defaultVisible: true }, +]; + +function readVisibleLogColumns(): LogColumnId[] { + const fallback = LOG_COLUMNS.filter((column) => column.defaultVisible).map((column) => column.id); + try { + const saved: unknown = JSON.parse(localStorage.getItem("crossrelay.dashboard.logColumns") ?? "null"); + if (!Array.isArray(saved)) return fallback; + const visible = saved.filter((value): value is LogColumnId => LOG_COLUMNS.some((column) => column.id === value)); + return ["time", ...visible.filter((column) => column !== "time")]; + } catch { + return fallback; + } +} + +function readDashboardTab(): DashboardTab { + const saved = localStorage.getItem("crossrelay.dashboard.tab"); + return saved === "providers" || saved === "models" || saved === "settings" ? saved : "logs"; +} function formatUptime(seconds: number): string { if (seconds < 60) return `${seconds}s`; @@ -24,6 +96,34 @@ export default function Dashboard() { const logs = useLogs(); const restore = useRestore(); const { show } = useToast(); + const [tab, setTab] = useState(readDashboardTab); + const [visibleColumns, setVisibleColumns] = useState(readVisibleLogColumns); + const entries = logs.data?.items ?? []; + const providerRows = useMemo(() => groupRequests(entries, "provider"), [entries]); + const modelRows = useMemo(() => groupRequests(entries, "model"), [entries]); + const selectTab = (next: DashboardTab) => { + setTab(next); + localStorage.setItem("crossrelay.dashboard.tab", next); + }; + const toggleColumn = (column: LogColumnId) => { + if (column === "time") return; + const next = visibleColumns.includes(column) + ? visibleColumns.filter((item) => item !== column) + : [...visibleColumns, column]; + setVisibleColumns(["time", ...LOG_COLUMNS.map((item) => item.id).filter((item) => item !== "time" && next.includes(item))]); + localStorage.setItem("crossrelay.dashboard.logColumns", JSON.stringify(next)); + }; + const resetColumns = () => { + const next = LOG_COLUMNS.filter((column) => column.defaultVisible).map((column) => column.id); + setVisibleColumns(next); + localStorage.removeItem("crossrelay.dashboard.logColumns"); + }; + const tabLabels: Record = { + logs: t("dashboard.tabLogs"), + providers: t("dashboard.tabProviders"), + models: t("dashboard.tabModels"), + settings: t("dashboard.tabSettings"), + }; if (status.isPending) { return
{t("common.loading")}
; @@ -110,61 +210,174 @@ export default function Dashboard() { - - - {t("dashboard.recentRequests")} - + ))} + + + + +
+ {tabLabels[tab]} + {tab !== "settings" && ( +

{t("dashboard.usageWindowHint")}

)} - {restore.isPending ? t("common.restoring") : t("common.restore")} - +
+ {tab === "logs" && ( + + )}
- - {logs.data && logs.data.items.length > 0 ? ( - - - - - - - - - - - - {logs.data.items.slice(0, 12).map((entry, index) => ( - - - - - - + + {tab === "logs" ? ( + entries.length === 0 ? ( +
{t("dashboard.emptyLogs")}
+ ) : ( +
+
{t("dashboard.tableTime")}{t("dashboard.tableModel")}{t("dashboard.tableStatus")}{t("dashboard.tableDuration")}{t("dashboard.tableTokens")}
{formatTime(entry.time)}{entry.model ?? entry.path} - {entry.status} - {entry.duration_ms} ms - {entry.input_tokens ?? 0} / {entry.output_tokens ?? 0} -
+ + + {visibleColumns.map((column) => ( + + ))} + + + + {entries.slice(0, 12).map((entry, index) => { + const speed = entry.output_tokens && entry.duration_ms > 0 + ? `≈${((entry.output_tokens * 1000) / entry.duration_ms).toFixed(1)} tok/s` + : "—"; + const cells: Record = { + time: ( + + ), + app: , + provider: , + model: , + input: , + output: , + cacheHit: , + cost: , + speed: , + firstToken: , + }; + return ( + + {visibleColumns.map((column) => ( + {cells[column]} + ))} + + ); + })} + +
{t(LOG_COLUMNS.find((item) => item.id === column)!.label)}
+ {formatTime(entry.time)} + {entry.status >= 400 && ( + + {entry.status} + + )} + {entry.app ?? "—"}{entry.provider ?? "—"}{entry.model ?? "—"}{entry.input_tokens ?? 0}{entry.output_tokens ?? 0}—{t("dashboard.tableUnpriced")}{speed}—
+ + ) + ) : tab === "providers" || tab === "models" ? ( +
+ + + + + + + + + + + - ))} - -
{t(tab === "providers" ? "dashboard.tableProvider" : "dashboard.tableModel")}{t("dashboard.tableApp")}{t("dashboard.summaryRequests")}{t("dashboard.summaryTokens")}{t("dashboard.summaryCost")}{t("dashboard.summarySuccessRate")}{t("dashboard.tableSpeed")}{t("dashboard.tableFirstToken")}
+ + + {(tab === "providers" ? providerRows : modelRows).length === 0 ? ( + {t("dashboard.emptyLogs")} + ) : (tab === "providers" ? providerRows : modelRows).map((row) => ( + + {row.label} + {row.app} + {row.requests} + {row.inputTokens + row.outputTokens} + {t("dashboard.tableUnpriced")} + {(((row.requests - row.failed) / row.requests) * 100).toFixed(1)}% + + {row.outputTokens > 0 && row.durationMS > 0 + ? `≈${((row.outputTokens * 1000) / row.durationMS).toFixed(1)} tok/s` + : "—"} + + — + + ))} + + +
) : ( -
{t("dashboard.emptyLogs")}
+
+
+

{t("dashboard.logColumns")}

+
+ {LOG_COLUMNS.filter((column) => column.id !== "time").map((column) => ( + + ))} +
+ +
+
+

{t("dashboard.dataLimits")}

+

{t("dashboard.dataLimitsDescription")}

+ + {t("dashboard.openConfigSettings")} + +
+
)}