Compare commits

...
6 Commits
Author SHA1 Message Date
kate522 fba5489280 Merge pull request 'Align usage dashboard with CCSwitch' (#3) from fix/ccswitch-usage-dashboard into main
Reviewed-on: #3
2026-10-19 11:38:24 +00:00
kate522andClaude Opus 5 272f604274 Align usage dashboard with CCSwitch
Filter request logs to model API calls and add request, provider, model, and settings tabs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 19:33:51 +08:00
kate522 f729910082 Merge pull request 'Add native Responses API upstream proxy' (#2) from fix/native-openai-responses into main
Reviewed-on: #2
2026-10-19 06:02:08 +00:00
kate522andClaude Opus 5 0ccb99ad83 Add native Responses API upstream proxy
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 14:01:36 +08:00
kate522 8ffd369448 Merge pull request 'Fix Codex Responses API base URL' (#1) from fix/codex-responses-base-url into main
Reviewed-on: #1
2026-10-19 05:33:35 +00:00
kate522andClaude Opus 5 416001ca39 Fix Codex Responses API base URL
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 13:26:52 +08:00
10 changed files with 688 additions and 69 deletions

No files matched your search

+148 -3
View File
@@ -19,15 +19,35 @@ import (
type fakeUpstream struct {
lastModel string
lastAuth string
lastPath string
lastRequest map[string]any
}
func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.lastPath = r.URL.Path
if r.Method == http.MethodGet && r.URL.Path == "/models" {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"object":"list","data":[{"id":"ds-chat"},{"id":"ds-haiku"}]}`)
return
}
if r.Method == http.MethodPost && r.URL.Path == "/v1/responses" {
f.lastAuth = r.Header.Get("Authorization")
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &f.lastRequest)
f.lastModel, _ = f.lastRequest["model"].(string)
stream, _ := f.lastRequest["stream"].(bool)
if stream {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "resp_native", "object": "response", "status": "completed", "model": f.lastModel,
"output": []any{}, "usage": map[string]any{"input_tokens": 9, "output_tokens": 2, "total_tokens": 11},
})
return
}
if r.URL.Path != "/chat/completions" {
w.WriteHeader(http.StatusNotFound)
return
@@ -290,6 +310,74 @@ func TestResponsesStream(t *testing.T) {
}
}
func TestNativeResponsesProtocol(t *testing.T) {
for _, stream := range []bool{false, true} {
name := "non-streaming"
if stream {
name = "streaming"
}
t.Run(name, func(t *testing.T) {
env := newCodexTestEnv(t)
provider, err := env.store.AddProvider("native-responses", "", env.upstreamURL+"/v1", "up-key",
config.Models{Default: "native-model"}, config.TargetCodex, config.ProtocolOpenAIResponses)
if err != nil {
t.Fatalf("AddProvider: %v", err)
}
if _, err := env.store.ActivateCodex(provider.ID); err != nil {
t.Fatalf("ActivateCodex: %v", err)
}
body := map[string]any{
"model": "gpt-5-codex",
"input": "What model are you?",
"stream": stream,
"reasoning": map[string]any{"effort": "high"},
"tools": []any{
map[string]any{"type": "web_search_preview"},
map[string]any{"type": "function", "name": "read_file", "parameters": map[string]any{"type": "object"}},
},
}
resp := env.post(t, "/v1/responses", body, env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
t.Fatalf("status = %d, body = %s", resp.StatusCode, data)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read response: %v", err)
}
if stream {
if !strings.Contains(string(data), "event: response.completed") {
t.Fatalf("stream response = %q", data)
}
} else if !strings.Contains(string(data), `"id":"resp_native"`) {
t.Fatalf("native response = %s", data)
}
if env.upstream.lastPath != "/v1/responses" {
t.Errorf("upstream path = %q, want /v1/responses", env.upstream.lastPath)
}
if env.upstream.lastAuth != "Bearer up-key" {
t.Errorf("upstream auth = %q", env.upstream.lastAuth)
}
if env.upstream.lastModel != "native-model" {
t.Errorf("upstream model = %q, want model mapping", env.upstream.lastModel)
}
if env.upstream.lastRequest["input"] != "What model are you?" || env.upstream.lastRequest["messages"] != nil {
t.Errorf("request was converted instead of passed through: %v", env.upstream.lastRequest)
}
if env.upstream.lastRequest["reasoning"] == nil {
t.Errorf("native reasoning field was dropped: %v", env.upstream.lastRequest)
}
tools := env.upstream.lastRequest["tools"].([]any)
if len(tools) != 2 || tools[0].(map[string]any)["type"] != "web_search_preview" {
t.Errorf("native tools were changed: %v", tools)
}
})
}
}
func TestResponsesRejectsBadToken(t *testing.T) {
env := newCodexTestEnv(t)
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", false), "crl_wrong")
@@ -331,7 +419,7 @@ func TestApplyAndRestoreCodex(t *testing.T) {
t.Fatalf("injection = %v", out["injection"])
}
table := injection["table"].(map[string]any)
if table["wire_api"] != "responses" || table["base_url"] != "http://127.0.0.1:18787" {
if table["wire_api"] != "responses" || table["base_url"] != "http://127.0.0.1:18787/v1" {
t.Errorf("injected table = %v", table)
}
if table["experimental_bearer_token"] != env.store.Config().RelayToken {
@@ -592,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)
@@ -613,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" {
@@ -637,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"])
}
+1 -1
View File
@@ -235,7 +235,7 @@ func (s *Server) applyCodex(w http.ResponseWriter, id string) {
return
}
injection, err := mgr.Apply(codexcfg.ApplyOptions{
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", s.port),
BaseURL: fmt.Sprintf("http://127.0.0.1:%d/v1", s.port),
Token: s.store.Config().RelayToken,
})
if err != nil {
+1
View File
@@ -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)
})
+155 -2
View File
@@ -1,20 +1,30 @@
package gateway
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
"gitea.rutua.cn/kate522/CrossRelay/internal/responses"
)
// handleResponses serves POST /v1/responses in both streaming and
// non-streaming modes.
func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 64<<20))
if err != nil {
writeResponsesError(w, http.StatusBadRequest, "invalid_request_error", "invalid request body: "+err.Error())
return
}
var req responses.Request
if err := decodeJSON(r, &req, 64<<20); err != nil {
if err := json.Unmarshal(body, &req); err != nil {
writeResponsesError(w, http.StatusBadRequest, "invalid_request_error", "invalid request body: "+err.Error())
return
}
@@ -25,6 +35,21 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
return
}
model := provider.ResolveModel(req.Model)
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
})
s.proxyNativeResponses(w, r, provider, body, req.Model, model, req.Stream, rec)
return
}
if provider.Protocol != config.ProtocolOpenAIChat {
writeResponsesError(w, http.StatusNotImplemented, "unsupported_protocol",
fmt.Sprintf("upstream protocol %q is not supported for Codex Responses requests", provider.Protocol))
return
}
chatReq, err := responses.ConvertRequest(&req, model)
if err != nil {
@@ -32,8 +57,8 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
return
}
rec := recordFrom(r)
rec.update(func(e *LogEntry) {
e.Provider = provider.Name
e.Model = model
e.InputTokens = responses.EstimateTokens(chatReq)
})
@@ -58,6 +83,134 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
func (s *Server) proxyNativeResponses(w http.ResponseWriter, r *http.Request, provider *config.Provider,
body []byte, requestedModel, model string, stream bool, rec *record) {
if model != requestedModel {
var payload map[string]json.RawMessage
if err := json.Unmarshal(body, &payload); err != nil || payload == nil {
writeResponsesError(w, http.StatusBadRequest, "invalid_request_error", "invalid request body: expected a JSON object")
return
}
encodedModel, err := json.Marshal(model)
if err != nil {
writeResponsesError(w, http.StatusInternalServerError, "api_error", "encode model: "+err.Error())
return
}
payload["model"] = encodedModel
body, err = json.Marshal(payload)
if err != nil {
writeResponsesError(w, http.StatusInternalServerError, "api_error", "encode request: "+err.Error())
return
}
}
upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost,
strings.TrimRight(provider.BaseURL, "/")+"/responses", bytes.NewReader(body))
if err != nil {
writeResponsesError(w, http.StatusBadGateway, "api_error", "create upstream request: "+err.Error())
return
}
for name, values := range r.Header {
if isHopByHopHeader(name) || strings.EqualFold(name, "Authorization") || strings.EqualFold(name, "X-API-Key") || strings.EqualFold(name, "Content-Length") {
continue
}
for _, value := range values {
upstreamReq.Header.Add(name, value)
}
}
upstreamReq.Header.Set("Content-Type", "application/json")
if r.Header.Get("Accept") == "" {
if stream {
upstreamReq.Header.Set("Accept", "text/event-stream")
} else {
upstreamReq.Header.Set("Accept", "application/json")
}
}
if provider.APIKey != "" {
upstreamReq.Header.Set("Authorization", "Bearer "+provider.APIKey)
}
upstreamResp, err := (&http.Client{Timeout: 30 * time.Minute}).Do(upstreamReq)
if err != nil {
rec.update(func(e *LogEntry) { e.Error = err.Error() })
writeResponsesError(w, http.StatusBadGateway, "api_error", fmt.Sprintf("upstream request failed: %v", err))
return
}
defer upstreamResp.Body.Close()
for name, values := range upstreamResp.Header {
if isHopByHopHeader(name) {
continue
}
for _, value := range values {
w.Header().Add(name, value)
}
}
if stream {
w.WriteHeader(upstreamResp.StatusCode)
if err := copyStreamingResponse(w, upstreamResp.Body); err != nil {
rec.update(func(e *LogEntry) { e.Error = err.Error() })
}
return
}
responseBody, err := io.ReadAll(io.LimitReader(upstreamResp.Body, 64<<20))
if err != nil {
rec.update(func(e *LogEntry) { e.Error = err.Error() })
writeResponsesError(w, http.StatusBadGateway, "api_error", "read upstream response: "+err.Error())
return
}
if upstreamResp.StatusCode >= http.StatusBadRequest {
rec.update(func(e *LogEntry) {
e.Error = fmt.Sprintf("upstream %d: %s", upstreamResp.StatusCode, strings.TrimSpace(string(responseBody)))
})
} else {
var result struct {
Usage *responses.Usage `json:"usage"`
}
if json.Unmarshal(responseBody, &result) == nil && result.Usage != nil {
rec.update(func(e *LogEntry) {
e.InputTokens = result.Usage.InputTokens
e.OutputTokens = result.Usage.OutputTokens
})
}
}
w.WriteHeader(upstreamResp.StatusCode)
_, _ = w.Write(responseBody)
}
func copyStreamingResponse(w http.ResponseWriter, body io.Reader) error {
buf := make([]byte, 32<<10)
for {
n, readErr := body.Read(buf)
if n > 0 {
if _, err := w.Write(buf[:n]); err != nil {
return err
}
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
if errors.Is(readErr, io.EOF) {
return nil
}
if readErr != nil {
return readErr
}
}
}
func isHopByHopHeader(name string) bool {
switch strings.ToLower(name) {
case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "proxy-connection",
"te", "trailer", "transfer-encoding", "upgrade":
return true
default:
return false
}
}
// handleResponsesStream relays a Chat Completions SSE stream as
// Responses events.
func (s *Server) handleResponsesStream(w http.ResponseWriter, r *http.Request, client *chatcmpl.Client,
+17 -3
View File
@@ -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)))
+58 -6
View File
@@ -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",
},
+2
View File
@@ -66,6 +66,8 @@ export interface LogEntry {
time: string;
method: string;
path: string;
app?: string;
provider?: string;
model?: string;
status: number;
duration_ms: number;
+1 -1
View File
@@ -65,7 +65,7 @@ beforeEach(() => {
table: {
name: "crossrelay",
wire_api: "responses",
base_url: "http://127.0.0.1:52917",
base_url: "http://127.0.0.1:52917/v1",
experimental_bearer_token: "crl_x",
requires_openai_auth: "false",
},
+42 -3
View File
@@ -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(<Dashboard />);
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(<Dashboard />);
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(<Dashboard />);
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();
});
});
+263 -50
View File
@@ -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<string, UsageGroup>();
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<DashboardTab>(readDashboardTab);
const [visibleColumns, setVisibleColumns] = useState<LogColumnId[]>(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<DashboardTab, string> = {
logs: t("dashboard.tabLogs"),
providers: t("dashboard.tabProviders"),
models: t("dashboard.tabModels"),
settings: t("dashboard.tabSettings"),
};
if (status.isPending) {
return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
@@ -110,61 +210,174 @@ export default function Dashboard() {
</Card>
</div>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>{t("dashboard.recentRequests")}</CardTitle>
<Button
variant="outline"
size="sm"
disabled={restore.isPending}
onClick={() =>
restore.mutate(
{ target: "claude" },
{
onSuccess: () => show(t("dashboard.restoreDone")),
onError: (err) => show(err.message, "error"),
},
)
}
<div role="tablist" aria-label={t("dashboard.usageTabs")} className="flex gap-1 overflow-x-auto border-b border-zinc-200">
{(["logs", "providers", "models", "settings"] as DashboardTab[]).map((value) => (
<button
key={value}
id={`dashboard-tab-${value}`}
type="button"
role="tab"
aria-selected={tab === value}
aria-controls="dashboard-tabpanel"
onClick={() => selectTab(value)}
className={`whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition-colors ${
tab === value
? "border-teal-700 text-teal-800"
: "border-transparent text-zinc-500 hover:text-zinc-800"
}`}
>
{restore.isPending ? (
<RotateCcw size={14} className="animate-spin" aria-hidden />
) : (
<Zap size={14} aria-hidden />
{tabLabels[value]}
</button>
))}
</div>
<Card>
<CardHeader className="flex-row items-center justify-between gap-3">
<div>
<CardTitle>{tabLabels[tab]}</CardTitle>
{tab !== "settings" && (
<p className="mt-1 text-xs text-zinc-500">{t("dashboard.usageWindowHint")}</p>
)}
{restore.isPending ? t("common.restoring") : t("common.restore")}
</Button>
</div>
{tab === "logs" && (
<Button
variant="outline"
size="sm"
disabled={restore.isPending}
onClick={() => restore.mutate(
{ target: "claude" },
{
onSuccess: () => show(t("dashboard.restoreDone")),
onError: (err) => show(err.message, "error"),
},
)}
>
{restore.isPending ? (
<RotateCcw size={14} className="animate-spin" aria-hidden />
) : (
<Zap size={14} aria-hidden />
)}
{restore.isPending ? t("common.restoring") : t("common.restore")}
</Button>
)}
</CardHeader>
<CardContent>
{logs.data && logs.data.items.length > 0 ? (
<Table>
<THead>
<TR>
<TH>{t("dashboard.tableTime")}</TH>
<TH>{t("dashboard.tableModel")}</TH>
<TH>{t("dashboard.tableStatus")}</TH>
<TH>{t("dashboard.tableDuration")}</TH>
<TH>{t("dashboard.tableTokens")}</TH>
</TR>
</THead>
<TBody>
{logs.data.items.slice(0, 12).map((entry, index) => (
<TR key={`${entry.time}-${index}`}>
<TD className="text-xs text-zinc-500">{formatTime(entry.time)}</TD>
<TD className="font-mono text-xs">{entry.model ?? entry.path}</TD>
<TD>
<Badge tone={entry.status < 400 ? "success" : "danger"}>{entry.status}</Badge>
</TD>
<TD className="text-xs">{entry.duration_ms} ms</TD>
<TD className="text-xs">
{entry.input_tokens ?? 0} / {entry.output_tokens ?? 0}
</TD>
<CardContent role="tabpanel" id="dashboard-tabpanel" aria-labelledby={`dashboard-tab-${tab}`}>
{tab === "logs" ? (
entries.length === 0 ? (
<div className="text-sm text-zinc-500">{t("dashboard.emptyLogs")}</div>
) : (
<div className="overflow-x-auto">
<Table className="min-w-[920px]">
<THead>
<TR>
{visibleColumns.map((column) => (
<TH key={column}>{t(LOG_COLUMNS.find((item) => item.id === column)!.label)}</TH>
))}
</TR>
</THead>
<TBody>
{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<LogColumnId, ReactNode> = {
time: (
<TD className="whitespace-nowrap text-xs text-zinc-500">
{formatTime(entry.time)}
{entry.status >= 400 && (
<Badge className="ml-1" tone="danger" title={entry.error}>
{entry.status}
</Badge>
)}
</TD>
),
app: <TD className="text-xs">{entry.app ?? "—"}</TD>,
provider: <TD className="text-xs">{entry.provider ?? "—"}</TD>,
model: <TD className="font-mono text-xs">{entry.model ?? "—"}</TD>,
input: <TD className="text-xs">{entry.input_tokens ?? 0}</TD>,
output: <TD className="text-xs">{entry.output_tokens ?? 0}</TD>,
cacheHit: <TD className="text-xs">—</TD>,
cost: <TD className="text-xs">{t("dashboard.tableUnpriced")}</TD>,
speed: <TD className="whitespace-nowrap text-xs">{speed}</TD>,
firstToken: <TD className="text-xs">—</TD>,
};
return (
<TR key={`${entry.time}-${index}`}>
{visibleColumns.map((column) => (
<Fragment key={column}>{cells[column]}</Fragment>
))}
</TR>
);
})}
</TBody>
</Table>
</div>
)
) : tab === "providers" || tab === "models" ? (
<div className="overflow-x-auto">
<Table className="min-w-[680px]">
<THead>
<TR>
<TH>{t(tab === "providers" ? "dashboard.tableProvider" : "dashboard.tableModel")}</TH>
<TH>{t("dashboard.tableApp")}</TH>
<TH>{t("dashboard.summaryRequests")}</TH>
<TH>{t("dashboard.summaryTokens")}</TH>
<TH>{t("dashboard.summaryCost")}</TH>
<TH>{t("dashboard.summarySuccessRate")}</TH>
<TH>{t("dashboard.tableSpeed")}</TH>
<TH>{t("dashboard.tableFirstToken")}</TH>
</TR>
))}
</TBody>
</Table>
</THead>
<TBody>
{(tab === "providers" ? providerRows : modelRows).length === 0 ? (
<TR><TD colSpan={8} className="text-sm text-zinc-500">{t("dashboard.emptyLogs")}</TD></TR>
) : (tab === "providers" ? providerRows : modelRows).map((row) => (
<TR key={row.key}>
<TD className={tab === "models" ? "font-mono text-xs" : "text-xs"}>{row.label}</TD>
<TD className="text-xs">{row.app}</TD>
<TD className="text-xs">{row.requests}</TD>
<TD className="text-xs">{row.inputTokens + row.outputTokens}</TD>
<TD className="text-xs">{t("dashboard.tableUnpriced")}</TD>
<TD className="text-xs">{(((row.requests - row.failed) / row.requests) * 100).toFixed(1)}%</TD>
<TD className="text-xs">
{row.outputTokens > 0 && row.durationMS > 0
? `≈${((row.outputTokens * 1000) / row.durationMS).toFixed(1)} tok/s`
: "—"}
</TD>
<TD className="text-xs">—</TD>
</TR>
))}
</TBody>
</Table>
</div>
) : (
<div className="text-sm text-zinc-500">{t("dashboard.emptyLogs")}</div>
<div className="flex flex-col gap-4">
<section className="rounded-lg border border-zinc-200 p-4">
<h3 className="text-sm font-semibold">{t("dashboard.logColumns")}</h3>
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
{LOG_COLUMNS.filter((column) => column.id !== "time").map((column) => (
<label key={column.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={visibleColumns.includes(column.id)}
onChange={() => toggleColumn(column.id)}
/>
{t(column.label)}
</label>
))}
</div>
<Button className="mt-3" variant="outline" size="sm" onClick={resetColumns}>
{t("dashboard.resetColumns")}
</Button>
</section>
<section className="rounded-lg border border-zinc-200 p-4">
<h3 className="text-sm font-semibold">{t("dashboard.dataLimits")}</h3>
<p className="mt-2 text-sm text-zinc-500">{t("dashboard.dataLimitsDescription")}</p>
<a className="mt-3 inline-flex text-sm font-medium text-teal-800 underline" href="/settings">
{t("dashboard.openConfigSettings")}
</a>
</section>
</div>
)}
</CardContent>
</Card>