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>
This commit is contained in:
1 parent
f729910082
commit
272f604274
8 files changed
+444
-64
No files matched your search
@@ -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) {
|
func TestStatusAndLogs(t *testing.T) {
|
||||||
env := newTestEnv(t)
|
env := newTestEnv(t)
|
||||||
resp := env.post(t, "/v1/messages", messagesBody("m", false), env.store.Config().RelayToken)
|
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)
|
t.Errorf("status = %v", status)
|
||||||
}
|
}
|
||||||
stats := status["stats"].(map[string]any)
|
stats := status["stats"].(map[string]any)
|
||||||
if stats["requests_total"].(float64) < 1 {
|
if stats["requests_total"].(float64) != 1 {
|
||||||
t.Errorf("stats = %v", stats)
|
t.Errorf("stats = %v, want exactly the one model request", stats)
|
||||||
}
|
}
|
||||||
active := status["active_claude_provider"].(map[string]any)
|
active := status["active_claude_provider"].(map[string]any)
|
||||||
if active["name"] != "test" {
|
if active["name"] != "test" {
|
||||||
@@ -725,6 +779,9 @@ func TestStatusAndLogs(t *testing.T) {
|
|||||||
if messageLog["model"] != "ds-chat" {
|
if messageLog["model"] != "ds-chat" {
|
||||||
t.Errorf("log model = %v, want the resolved model", messageLog["model"])
|
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 {
|
if messageLog["status"].(float64) != 200 {
|
||||||
t.Errorf("log status = %v", messageLog["status"])
|
t.Errorf("log status = %v", messageLog["status"])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
rec := recordFrom(r)
|
rec := recordFrom(r)
|
||||||
rec.update(func(e *LogEntry) {
|
rec.update(func(e *LogEntry) {
|
||||||
|
e.Provider = provider.Name
|
||||||
e.Model = model
|
e.Model = model
|
||||||
e.InputTokens = anthropic.EstimateTokens(&req)
|
e.InputTokens = anthropic.EstimateTokens(&req)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
|||||||
rec := recordFrom(r)
|
rec := recordFrom(r)
|
||||||
if provider.Protocol == config.ProtocolOpenAIResponses {
|
if provider.Protocol == config.ProtocolOpenAIResponses {
|
||||||
rec.update(func(e *LogEntry) {
|
rec.update(func(e *LogEntry) {
|
||||||
|
e.Provider = provider.Name
|
||||||
e.Model = model
|
e.Model = model
|
||||||
e.InputTokens = (len(body) + 3) / 4
|
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) {
|
rec.update(func(e *LogEntry) {
|
||||||
|
e.Provider = provider.Name
|
||||||
e.Model = model
|
e.Model = model
|
||||||
e.InputTokens = responses.EstimateTokens(chatReq)
|
e.InputTokens = responses.EstimateTokens(chatReq)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type LogEntry struct {
|
|||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
App string `json:"app,omitempty"`
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
DurationMS int64 `json:"duration_ms"`
|
DurationMS int64 `json:"duration_ms"`
|
||||||
@@ -71,9 +73,9 @@ func (s *Server) Port() int { return s.port }
|
|||||||
// Handler returns the full HTTP handler.
|
// Handler returns the full HTTP handler.
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
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/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("GET /api/status", s.handleStatus)
|
||||||
mux.HandleFunc("POST /api/fetch-models", s.handleFetchModels)
|
mux.HandleFunc("POST /api/fetch-models", s.handleFetchModels)
|
||||||
mux.HandleFunc("GET /api/providers", s.handleListProviders)
|
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("GET /api/settings", s.handleGetSettings)
|
||||||
mux.HandleFunc("PUT /api/settings", s.handleUpdateSettings)
|
mux.HandleFunc("PUT /api/settings", s.handleUpdateSettings)
|
||||||
mux.HandleFunc("GET /api/logs", s.handleLogs)
|
mux.HandleFunc("GET /api/logs", s.handleLogs)
|
||||||
return s.withLogging(mux)
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
// withAuth protects the Anthropic-compatible endpoints with the local
|
// withAuth protects the Anthropic-compatible endpoints with the local
|
||||||
@@ -139,6 +141,17 @@ func (rec *record) update(fn func(*LogEntry)) {
|
|||||||
fn(&rec.entry)
|
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 {
|
func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -146,6 +159,7 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
|
|||||||
Time: start,
|
Time: start,
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
Path: r.URL.Path,
|
Path: r.URL.Path,
|
||||||
|
App: requestApp(r.URL.Path),
|
||||||
}}
|
}}
|
||||||
ww := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
ww := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||||
next.ServeHTTP(ww, r.WithContext(context.WithValue(r.Context(), recordKey{}, rec)))
|
next.ServeHTTP(ww, r.WithContext(context.WithValue(r.Context(), recordKey{}, rec)))
|
||||||
|
|||||||
+58
-6
@@ -46,13 +46,39 @@ export const zhCN = {
|
|||||||
tokensIn: "输入 Token",
|
tokensIn: "输入 Token",
|
||||||
tokensOut: "输出 Token",
|
tokensOut: "输出 Token",
|
||||||
recentRequests: "最近请求",
|
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 配置",
|
quickApply: "写入 Claude 配置",
|
||||||
restoreDone: "已还原为官方配置",
|
restoreDone: "已还原为官方配置",
|
||||||
tableTime: "时间",
|
tableTime: "时间",
|
||||||
|
tableApp: "应用",
|
||||||
|
tableProvider: "供应商",
|
||||||
tableModel: "模型",
|
tableModel: "模型",
|
||||||
tableStatus: "状态",
|
tableInput: "新增输入",
|
||||||
tableDuration: "耗时",
|
tableOutput: "输出",
|
||||||
tableTokens: "Token",
|
tableCacheHit: "缓存命中",
|
||||||
|
tableCost: "成本",
|
||||||
|
tableUnpriced: "未定价",
|
||||||
|
tableSpeed: "速度",
|
||||||
|
tableFirstToken: "首字",
|
||||||
emptyLogs: "暂无请求记录",
|
emptyLogs: "暂无请求记录",
|
||||||
statsTitle: "统计",
|
statsTitle: "统计",
|
||||||
},
|
},
|
||||||
@@ -189,13 +215,39 @@ export const enUS = {
|
|||||||
tokensIn: "Input tokens",
|
tokensIn: "Input tokens",
|
||||||
tokensOut: "Output tokens",
|
tokensOut: "Output tokens",
|
||||||
recentRequests: "Recent requests",
|
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",
|
quickApply: "Write to Claude config",
|
||||||
restoreDone: "Official config restored",
|
restoreDone: "Official config restored",
|
||||||
tableTime: "Time",
|
tableTime: "Time",
|
||||||
|
tableApp: "App",
|
||||||
|
tableProvider: "Provider",
|
||||||
tableModel: "Model",
|
tableModel: "Model",
|
||||||
tableStatus: "Status",
|
tableInput: "Fresh Input",
|
||||||
tableDuration: "Duration",
|
tableOutput: "Output",
|
||||||
tableTokens: "Tokens",
|
tableCacheHit: "Cache Hit",
|
||||||
|
tableCost: "Cost",
|
||||||
|
tableUnpriced: "Unpriced",
|
||||||
|
tableSpeed: "Speed",
|
||||||
|
tableFirstToken: "First token",
|
||||||
emptyLogs: "No requests yet",
|
emptyLogs: "No requests yet",
|
||||||
statsTitle: "Stats",
|
statsTitle: "Stats",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface LogEntry {
|
|||||||
time: string;
|
time: string;
|
||||||
method: string;
|
method: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
app?: string;
|
||||||
|
provider?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
status: number;
|
status: number;
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import { screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { renderWithProviders } from "@/test/utils";
|
import { renderWithProviders } from "@/test/utils";
|
||||||
|
|
||||||
const getStatus = vi.fn();
|
const getStatus = vi.fn();
|
||||||
@@ -26,6 +27,7 @@ import Dashboard from "./Dashboard";
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
getStatus.mockResolvedValue({
|
getStatus.mockResolvedValue({
|
||||||
running: true,
|
running: true,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -43,6 +45,8 @@ beforeEach(() => {
|
|||||||
time: "2026-10-18T08:00:00Z",
|
time: "2026-10-18T08:00:00Z",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/v1/messages",
|
path: "/v1/messages",
|
||||||
|
app: "Claude Code",
|
||||||
|
provider: "DeepSeek",
|
||||||
model: "deepseek-chat",
|
model: "deepseek-chat",
|
||||||
status: 200,
|
status: 200,
|
||||||
duration_ms: 820,
|
duration_ms: 820,
|
||||||
@@ -54,14 +58,49 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Dashboard", () => {
|
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 () => {
|
it("renders gateway status, active provider and stats", async () => {
|
||||||
renderWithProviders(<Dashboard />);
|
renderWithProviders(<Dashboard />);
|
||||||
expect(await screen.findByText("运行中")).toBeInTheDocument();
|
expect(await screen.findByText("运行中")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/52917/)).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("7")).toBeInTheDocument(); // requests total
|
||||||
expect(screen.getByText("deepseek-chat")).toBeInTheDocument(); // log model
|
expect(screen.getByText("deepseek-chat")).toBeInTheDocument(); // log model
|
||||||
// 200 shows up both as output tokens and as the log row status.
|
for (const header of ["时间", "应用", "供应商", "模型", "新增输入", "输出", "缓存命中", "成本", "速度", "首字"]) {
|
||||||
expect(screen.getAllByText("200").length).toBeGreaterThanOrEqual(2);
|
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
@@ -1,3 +1,4 @@
|
|||||||
|
import { Fragment, useMemo, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { RotateCcw, Zap } from "lucide-react";
|
import { RotateCcw, Zap } from "lucide-react";
|
||||||
import { useRestore, useStatus, useLogs } from "@/lib/queries";
|
import { useRestore, useStatus, useLogs } from "@/lib/queries";
|
||||||
@@ -6,6 +7,77 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table";
|
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 {
|
function formatUptime(seconds: number): string {
|
||||||
if (seconds < 60) return `${seconds}s`;
|
if (seconds < 60) return `${seconds}s`;
|
||||||
@@ -24,6 +96,34 @@ export default function Dashboard() {
|
|||||||
const logs = useLogs();
|
const logs = useLogs();
|
||||||
const restore = useRestore();
|
const restore = useRestore();
|
||||||
const { show } = useToast();
|
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) {
|
if (status.isPending) {
|
||||||
return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
|
return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
|
||||||
@@ -110,61 +210,174 @@ export default function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div role="tablist" aria-label={t("dashboard.usageTabs")} className="flex gap-1 overflow-x-auto border-b border-zinc-200">
|
||||||
<CardHeader className="flex-row items-center justify-between">
|
{(["logs", "providers", "models", "settings"] as DashboardTab[]).map((value) => (
|
||||||
<CardTitle>{t("dashboard.recentRequests")}</CardTitle>
|
<button
|
||||||
<Button
|
key={value}
|
||||||
variant="outline"
|
id={`dashboard-tab-${value}`}
|
||||||
size="sm"
|
type="button"
|
||||||
disabled={restore.isPending}
|
role="tab"
|
||||||
onClick={() =>
|
aria-selected={tab === value}
|
||||||
restore.mutate(
|
aria-controls="dashboard-tabpanel"
|
||||||
{ target: "claude" },
|
onClick={() => selectTab(value)}
|
||||||
{
|
className={`whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
onSuccess: () => show(t("dashboard.restoreDone")),
|
tab === value
|
||||||
onError: (err) => show(err.message, "error"),
|
? "border-teal-700 text-teal-800"
|
||||||
},
|
: "border-transparent text-zinc-500 hover:text-zinc-800"
|
||||||
)
|
}`}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{restore.isPending ? (
|
{tabLabels[value]}
|
||||||
<RotateCcw size={14} className="animate-spin" aria-hidden />
|
</button>
|
||||||
) : (
|
))}
|
||||||
<Zap size={14} aria-hidden />
|
</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")}
|
</div>
|
||||||
</Button>
|
{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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent role="tabpanel" id="dashboard-tabpanel" aria-labelledby={`dashboard-tab-${tab}`}>
|
||||||
{logs.data && logs.data.items.length > 0 ? (
|
{tab === "logs" ? (
|
||||||
<Table>
|
entries.length === 0 ? (
|
||||||
<THead>
|
<div className="text-sm text-zinc-500">{t("dashboard.emptyLogs")}</div>
|
||||||
<TR>
|
) : (
|
||||||
<TH>{t("dashboard.tableTime")}</TH>
|
<div className="overflow-x-auto">
|
||||||
<TH>{t("dashboard.tableModel")}</TH>
|
<Table className="min-w-[920px]">
|
||||||
<TH>{t("dashboard.tableStatus")}</TH>
|
<THead>
|
||||||
<TH>{t("dashboard.tableDuration")}</TH>
|
<TR>
|
||||||
<TH>{t("dashboard.tableTokens")}</TH>
|
{visibleColumns.map((column) => (
|
||||||
</TR>
|
<TH key={column}>{t(LOG_COLUMNS.find((item) => item.id === column)!.label)}</TH>
|
||||||
</THead>
|
))}
|
||||||
<TBody>
|
</TR>
|
||||||
{logs.data.items.slice(0, 12).map((entry, index) => (
|
</THead>
|
||||||
<TR key={`${entry.time}-${index}`}>
|
<TBody>
|
||||||
<TD className="text-xs text-zinc-500">{formatTime(entry.time)}</TD>
|
{entries.slice(0, 12).map((entry, index) => {
|
||||||
<TD className="font-mono text-xs">{entry.model ?? entry.path}</TD>
|
const speed = entry.output_tokens && entry.duration_ms > 0
|
||||||
<TD>
|
? `≈${((entry.output_tokens * 1000) / entry.duration_ms).toFixed(1)} tok/s`
|
||||||
<Badge tone={entry.status < 400 ? "success" : "danger"}>{entry.status}</Badge>
|
: "—";
|
||||||
</TD>
|
const cells: Record<LogColumnId, ReactNode> = {
|
||||||
<TD className="text-xs">{entry.duration_ms} ms</TD>
|
time: (
|
||||||
<TD className="text-xs">
|
<TD className="whitespace-nowrap text-xs text-zinc-500">
|
||||||
{entry.input_tokens ?? 0} / {entry.output_tokens ?? 0}
|
{formatTime(entry.time)}
|
||||||
</TD>
|
{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>
|
</TR>
|
||||||
))}
|
</THead>
|
||||||
</TBody>
|
<TBody>
|
||||||
</Table>
|
{(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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in new issue
Block a user