From 5100dfceee41e4d047266f131d559405c07023b1 Mon Sep 17 00:00:00 2001 From: GcsSloop Date: Wed, 24 Jun 2026 20:23:31 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E8=B4=A8=E9=87=8F=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/dashboard_handler.go | 25 +++- .../internal/api/dashboard_handler_test.go | 77 ++++++++-- backend/internal/bootstrap/bootstrap.go | 1 + backend/internal/bootstrap/bootstrap_test.go | 7 + backend/internal/usage/repository.go | 103 +++++++++++++ backend/internal/usage/repository_test.go | 141 ++++++++++++++++++ backend/internal/usage/types.go | 20 +++ docs/testing.md | 3 + .../src/features/stats/StatsPage.test.tsx | 77 +++++++--- frontend/src/features/stats/StatsPage.tsx | 116 +++++++++++--- frontend/src/lib/api.ts | 60 +++++++- frontend/src/styles.css | 18 +++ 12 files changed, 587 insertions(+), 61 deletions(-) diff --git a/backend/internal/api/dashboard_handler.go b/backend/internal/api/dashboard_handler.go index 967b804..ccb5e73 100644 --- a/backend/internal/api/dashboard_handler.go +++ b/backend/internal/api/dashboard_handler.go @@ -23,8 +23,9 @@ func SetDashboardNowForTest(fn func() time.Time) func() { type DashboardUsageSummary interface { SummarizeEvents(filter usage.EventFilter) (usage.EventSummary, error) + AnalyzeRequestQuality(filter usage.EventFilter) (usage.RequestQuality, error) TrendEventsByHour(filter usage.EventFilter) ([]usage.TrendPoint, error) - ListRecentEvents(filter usage.EventFilter) ([]usage.Event, error) + ListRecentEventsPage(filter usage.EventFilter) (usage.EventPage, error) ModelDistribution(filter usage.EventFilter) ([]usage.ModelDistributionPoint, error) } @@ -71,6 +72,13 @@ func (h *DashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } writeJSON(w, http.StatusOK, summary) + case r.Method == http.MethodGet && r.URL.Path == "/dashboard/request-quality": + quality, err := h.usage.AnalyzeRequestQuality(filter) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, quality) case r.Method == http.MethodGet && r.URL.Path == "/dashboard/trends": trends, err := h.usage.TrendEventsByHour(filter) if err != nil { @@ -79,7 +87,7 @@ func (h *DashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, trends) case r.Method == http.MethodGet && r.URL.Path == "/dashboard/recent-events": - events, err := h.usage.ListRecentEvents(filter) + events, err := h.usage.ListRecentEventsPage(filter) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -177,9 +185,22 @@ func dashboardEventFilter(r *http.Request) usage.EventFilter { filter.Limit = limit } } + if raw := query.Get("page_size"); raw != "" { + if pageSize, err := strconv.Atoi(raw); err == nil && pageSize > 0 { + filter.Limit = pageSize + } + } if filter.Limit == 0 { filter.Limit = 20 } + if filter.Limit > 200 { + filter.Limit = 200 + } + if raw := query.Get("page"); raw != "" { + if page, err := strconv.Atoi(raw); err == nil && page > 1 { + filter.Offset = (page - 1) * filter.Limit + } + } return filter } diff --git a/backend/internal/api/dashboard_handler_test.go b/backend/internal/api/dashboard_handler_test.go index 80d3174..9dfe353 100644 --- a/backend/internal/api/dashboard_handler_test.go +++ b/backend/internal/api/dashboard_handler_test.go @@ -77,28 +77,73 @@ func TestDashboardHandlerTrends(t *testing.T) { func TestDashboardHandlerRecentEvents(t *testing.T) { t.Parallel() - handler := api.NewDashboardHandler(&dashboardUsageStub{ - recent: []usage.Event{ - {ID: 2, AccountID: 9, Model: "gpt-5.2", Status: "completed", TotalTokens: 1500, EstimatedCost: 0.42, CreatedAt: time.Date(2026, 3, 15, 10, 5, 0, 0, time.UTC)}, + stub := &dashboardUsageStub{ + recentPage: usage.EventPage{ + Items: []usage.Event{ + {ID: 2, AccountID: 9, Model: "gpt-5.2", Status: "completed", TotalTokens: 1500, EstimatedCost: 0.42, CreatedAt: time.Date(2026, 3, 15, 10, 5, 0, 0, time.UTC)}, + }, + Total: 12, + Page: 2, + PageSize: 1, }, - }) + } + handler := api.NewDashboardHandler(stub) - req := httptest.NewRequest(http.MethodGet, "/dashboard/recent-events?limit=20", nil) + req := httptest.NewRequest(http.MethodGet, "/dashboard/recent-events?page=2&page_size=1", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("GET /dashboard/recent-events status = %d, want %d", rec.Code, http.StatusOK) } - var got []usage.Event + var got usage.EventPage if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("Unmarshal returned error: %v", err) } - if len(got) != 1 { - t.Fatalf("len(got) = %d, want 1", len(got)) + if got.Total != 12 || got.Page != 2 || got.PageSize != 1 { + t.Fatalf("page metadata = %+v, want total=12 page=2 page_size=1", got) + } + if len(got.Items) != 1 { + t.Fatalf("len(got.Items) = %d, want 1", len(got.Items)) + } + if got.Items[0].ID != 2 || got.Items[0].EstimatedCost != 0.42 { + t.Fatalf("recent = %+v", got.Items[0]) + } + if stub.lastFilter.Limit != 1 || stub.lastFilter.Offset != 1 { + t.Fatalf("filter limit/offset = %d/%d, want 1/1", stub.lastFilter.Limit, stub.lastFilter.Offset) + } +} + +func TestDashboardHandlerRequestQuality(t *testing.T) { + t.Parallel() + + handler := api.NewDashboardHandler(&dashboardUsageStub{ + quality: usage.RequestQuality{ + RequestCount: 100, + SuccessCount: 95, + FailureCount: 5, + SuccessRate: 0.95, + AvgLatencyMS: 250, + P95LatencyMS: 800, + P99LatencyMS: 1200, + MinLatencyMS: 20, + MaxLatencyMS: 1600, + }, + }) + + req := httptest.NewRequest(http.MethodGet, "/dashboard/request-quality?range=24h&account_id=9", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET /dashboard/request-quality status = %d, want %d", rec.Code, http.StatusOK) + } + + var got usage.RequestQuality + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) } - if got[0].ID != 2 || got[0].EstimatedCost != 0.42 { - t.Fatalf("recent = %+v", got[0]) + if got.SuccessRate != 0.95 || got.P95LatencyMS != 800 || got.P99LatencyMS != 1200 { + t.Fatalf("quality = %+v, want success_rate=0.95 p95=800 p99=1200", got) } } @@ -288,8 +333,9 @@ func TestDashboardHandlerDoesNotCompactEventsOnReadRequests(t *testing.T) { type dashboardUsageStub struct { summary usage.EventSummary + quality usage.RequestQuality trends []usage.TrendPoint - recent []usage.Event + recentPage usage.EventPage modelDistribution []usage.ModelDistributionPoint lastFilter usage.EventFilter compactCalls int @@ -305,9 +351,14 @@ func (s *dashboardUsageStub) TrendEventsByHour(filter usage.EventFilter) ([]usag return s.trends, nil } -func (s *dashboardUsageStub) ListRecentEvents(filter usage.EventFilter) ([]usage.Event, error) { +func (s *dashboardUsageStub) ListRecentEventsPage(filter usage.EventFilter) (usage.EventPage, error) { + s.lastFilter = filter + return s.recentPage, nil +} + +func (s *dashboardUsageStub) AnalyzeRequestQuality(filter usage.EventFilter) (usage.RequestQuality, error) { s.lastFilter = filter - return s.recent, nil + return s.quality, nil } func (s *dashboardUsageStub) ModelDistribution(filter usage.EventFilter) ([]usage.ModelDistributionPoint, error) { diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index d2a1be4..8d8a093 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -154,6 +154,7 @@ func NewApp(_ context.Context, cfg Config) (*App, error) { apiMux.Handle("/monitoring/overview", api.NewMonitoringHandler(accountRepo, usageRepo)) dashboardHandler := api.NewDashboardHandler(usageRepo, api.WithDashboardStateEvents(stateEvents)) apiMux.Handle("/dashboard/summary", dashboardHandler) + apiMux.Handle("/dashboard/request-quality", dashboardHandler) apiMux.Handle("/dashboard/trends", dashboardHandler) apiMux.Handle("/dashboard/recent-events", dashboardHandler) apiMux.Handle("/dashboard/model-distribution", dashboardHandler) diff --git a/backend/internal/bootstrap/bootstrap_test.go b/backend/internal/bootstrap/bootstrap_test.go index 02df391..3737156 100644 --- a/backend/internal/bootstrap/bootstrap_test.go +++ b/backend/internal/bootstrap/bootstrap_test.go @@ -52,6 +52,13 @@ func TestNewApp(t *testing.T) { t.Fatalf("GET /ai-router/api/accounts status = %d, want %d", apiRec.Code, http.StatusOK) } + qualityReq := httptest.NewRequest(http.MethodGet, "/ai-router/api/dashboard/request-quality?range=24h", nil) + qualityRec := httptest.NewRecorder() + app.Handler().ServeHTTP(qualityRec, qualityReq) + if qualityRec.Code != http.StatusOK { + t.Fatalf("GET /ai-router/api/dashboard/request-quality status = %d, want %d; body=%s", qualityRec.Code, http.StatusOK, qualityRec.Body.String()) + } + responsesReq := httptest.NewRequest(http.MethodPost, "/ai-router/api/responses", nil) responsesRec := httptest.NewRecorder() app.Handler().ServeHTTP(responsesRec, responsesReq) diff --git a/backend/internal/usage/repository.go b/backend/internal/usage/repository.go index 464699d..8b55b0a 100644 --- a/backend/internal/usage/repository.go +++ b/backend/internal/usage/repository.go @@ -3,6 +3,7 @@ package usage import ( "database/sql" "fmt" + "math" "sort" "time" ) @@ -16,7 +17,9 @@ type Repository interface { SaveEvent(event Event) error CompactEvents(now time.Time) error ListRecentEvents(filter EventFilter) ([]Event, error) + ListRecentEventsPage(filter EventFilter) (EventPage, error) SummarizeEvents(filter EventFilter) (EventSummary, error) + AnalyzeRequestQuality(filter EventFilter) (RequestQuality, error) TrendEventsByHour(filter EventFilter) ([]TrendPoint, error) ModelDistribution(filter EventFilter) ([]ModelDistributionPoint, error) } @@ -324,6 +327,10 @@ func (r *SQLiteRepository) ListRecentEvents(filter EventFilter) ([]Event, error) limit = 50 } args = append(args, limit) + if filter.Offset > 0 { + query += ` OFFSET ?` + args = append(args, filter.Offset) + } rows, err := r.db.Query(query, args...) if err != nil { @@ -363,6 +370,44 @@ func (r *SQLiteRepository) ListRecentEvents(filter EventFilter) ([]Event, error) return events, nil } +func (r *SQLiteRepository) ListRecentEventsPage(filter EventFilter) (EventPage, error) { + limit := filter.Limit + if limit <= 0 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + + countQuery := `SELECT COUNT(*) FROM usage_events` + where, args := eventFilterWhere(filter) + countQuery += where + var total int64 + if err := r.db.QueryRow(countQuery, args...).Scan(&total); err != nil { + return EventPage{}, fmt.Errorf("count recent usage events: %w", err) + } + + filter.Limit = limit + filter.Offset = offset + items, err := r.ListRecentEvents(filter) + if err != nil { + return EventPage{}, err + } + if items == nil { + items = []Event{} + } + return EventPage{ + Items: items, + Total: total, + Page: offset/limit + 1, + PageSize: limit, + }, nil +} + func (r *SQLiteRepository) SummarizeEvents(filter EventFilter) (EventSummary, error) { var summary EventSummary rows, err := r.loadAggregateRows(filter) @@ -383,6 +428,50 @@ func (r *SQLiteRepository) SummarizeEvents(filter EventFilter) (EventSummary, er return summary, nil } +func (r *SQLiteRepository) AnalyzeRequestQuality(filter EventFilter) (RequestQuality, error) { + query := `SELECT status, latency_ms FROM usage_events` + where, args := eventFilterWhere(filter) + query += where + ` ORDER BY latency_ms ASC` + + rows, err := r.db.Query(query, args...) + if err != nil { + return RequestQuality{}, fmt.Errorf("query request quality events: %w", err) + } + defer rows.Close() + + var quality RequestQuality + latencies := make([]float64, 0) + var latencySum float64 + for rows.Next() { + var status string + var latency float64 + if err := rows.Scan(&status, &latency); err != nil { + return RequestQuality{}, fmt.Errorf("scan request quality event: %w", err) + } + quality.RequestCount++ + if status == "completed" { + quality.SuccessCount++ + } else { + quality.FailureCount++ + } + latencies = append(latencies, latency) + latencySum += latency + } + if err := rows.Err(); err != nil { + return RequestQuality{}, fmt.Errorf("iterate request quality events: %w", err) + } + if quality.RequestCount == 0 { + return quality, nil + } + quality.SuccessRate = float64(quality.SuccessCount) / float64(quality.RequestCount) + quality.AvgLatencyMS = latencySum / float64(quality.RequestCount) + quality.MinLatencyMS = latencies[0] + quality.MaxLatencyMS = latencies[len(latencies)-1] + quality.P95LatencyMS = percentileNearestRank(latencies, 0.95) + quality.P99LatencyMS = percentileNearestRank(latencies, 0.99) + return quality, nil +} + func (r *SQLiteRepository) TrendEventsByHour(filter EventFilter) ([]TrendPoint, error) { rows, err := r.loadAggregateRows(filter) if err != nil { @@ -522,6 +611,20 @@ func sortModelDistribution(points []ModelDistributionPoint) { }) } +func percentileNearestRank(sortedValues []float64, percentile float64) float64 { + if len(sortedValues) == 0 { + return 0 + } + rank := int(math.Ceil(percentile * float64(len(sortedValues)))) + if rank < 1 { + rank = 1 + } + if rank > len(sortedValues) { + rank = len(sortedValues) + } + return sortedValues[rank-1] +} + func (r *SQLiteRepository) loadAggregateRows(filter EventFilter) ([]RollupPoint, error) { rows := make([]RollupPoint, 0) diff --git a/backend/internal/usage/repository_test.go b/backend/internal/usage/repository_test.go index d276bc4..d9acc7d 100644 --- a/backend/internal/usage/repository_test.go +++ b/backend/internal/usage/repository_test.go @@ -442,6 +442,147 @@ func TestSQLiteRepositorySaveEventListRecentAndSummarize(t *testing.T) { } } +func TestSQLiteRepositoryAnalyzesRequestQualityWithAccountFilter(t *testing.T) { + t.Parallel() + + store, err := sqlitestore.Open(filepath.Join(t.TempDir(), "router.sqlite")) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + t.Cleanup(func() { + _ = store.Close() + }) + + repo := usage.NewSQLiteRepository(store.DB()) + from := time.Date(2026, 3, 15, 8, 0, 0, 0, time.UTC) + to := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC) + for i := 1; i <= 100; i++ { + status := "completed" + if i > 95 { + status = "rate_limited" + } + if err := repo.SaveEvent(usage.Event{ + AccountID: 9, + ProviderType: "openai", + RequestKind: "responses", + Model: "gpt-5.2", + Status: status, + LatencyMS: float64(i), + CreatedAt: from.Add(time.Duration(i) * time.Minute), + }); err != nil { + t.Fatalf("SaveEvent(%d) returned error: %v", i, err) + } + } + if err := repo.SaveEvent(usage.Event{ + AccountID: 7, + ProviderType: "openai", + RequestKind: "chat_completions", + Model: "gpt-5.2", + Status: "completed", + LatencyMS: 9999, + CreatedAt: from.Add(30 * time.Minute), + }); err != nil { + t.Fatalf("SaveEvent(other account) returned error: %v", err) + } + + accountID := int64(9) + quality, err := repo.AnalyzeRequestQuality(usage.EventFilter{ + From: &from, + To: &to, + AccountID: &accountID, + }) + if err != nil { + t.Fatalf("AnalyzeRequestQuality returned error: %v", err) + } + if quality.RequestCount != 100 || quality.SuccessCount != 95 || quality.FailureCount != 5 { + t.Fatalf("quality counts = %+v, want 100/95/5", quality) + } + if quality.SuccessRate != 0.95 { + t.Fatalf("SuccessRate = %v, want 0.95", quality.SuccessRate) + } + if quality.AvgLatencyMS != 50.5 { + t.Fatalf("AvgLatencyMS = %v, want 50.5", quality.AvgLatencyMS) + } + if quality.P95LatencyMS != 95 || quality.P99LatencyMS != 99 { + t.Fatalf("percentiles = p95=%v p99=%v, want 95/99", quality.P95LatencyMS, quality.P99LatencyMS) + } + if quality.MinLatencyMS != 1 || quality.MaxLatencyMS != 100 { + t.Fatalf("min/max latency = %v/%v, want 1/100", quality.MinLatencyMS, quality.MaxLatencyMS) + } +} + +func TestSQLiteRepositoryListRecentEventsPage(t *testing.T) { + t.Parallel() + + store, err := sqlitestore.Open(filepath.Join(t.TempDir(), "router.sqlite")) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + t.Cleanup(func() { + _ = store.Close() + }) + + repo := usage.NewSQLiteRepository(store.DB()) + base := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + for i := 1; i <= 5; i++ { + if err := repo.SaveEvent(usage.Event{ + AccountID: 9, + ProviderType: "openai", + RequestKind: "responses", + Model: "gpt-5.2", + Status: "completed", + TotalTokens: int64(i * 100), + LatencyMS: float64(i * 10), + CreatedAt: base.Add(time.Duration(i) * time.Minute), + }); err != nil { + t.Fatalf("SaveEvent(%d) returned error: %v", i, err) + } + } + + accountID := int64(9) + page, err := repo.ListRecentEventsPage(usage.EventFilter{ + AccountID: &accountID, + Limit: 2, + Offset: 2, + }) + if err != nil { + t.Fatalf("ListRecentEventsPage returned error: %v", err) + } + if page.Total != 5 || page.Page != 2 || page.PageSize != 2 { + t.Fatalf("page metadata = %+v, want total=5 page=2 page_size=2", page) + } + if len(page.Items) != 2 { + t.Fatalf("len(page.Items) = %d, want 2", len(page.Items)) + } + if page.Items[0].TotalTokens != 300 || page.Items[1].TotalTokens != 200 { + t.Fatalf("page items = %+v, want second page in newest-first order", page.Items) + } +} + +func TestSQLiteRepositoryListRecentEventsPageReturnsEmptyItemsSlice(t *testing.T) { + t.Parallel() + + store, err := sqlitestore.Open(filepath.Join(t.TempDir(), "router.sqlite")) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + t.Cleanup(func() { + _ = store.Close() + }) + + repo := usage.NewSQLiteRepository(store.DB()) + page, err := repo.ListRecentEventsPage(usage.EventFilter{Limit: 20}) + if err != nil { + t.Fatalf("ListRecentEventsPage returned error: %v", err) + } + if page.Items == nil { + t.Fatal("page.Items is nil, want empty slice for stable JSON array encoding") + } + if len(page.Items) != 0 || page.Total != 0 { + t.Fatalf("page = %+v, want empty result", page) + } +} + func TestSQLiteRepositoryTrendEventsByHour(t *testing.T) { t.Parallel() diff --git a/backend/internal/usage/types.go b/backend/internal/usage/types.go index aa82a84..8f86838 100644 --- a/backend/internal/usage/types.go +++ b/backend/internal/usage/types.go @@ -56,6 +56,7 @@ type EventFilter struct { ServerUserID *int64 Model string Limit int + Offset int BucketSize time.Duration BucketLocation *time.Location IncludeZeroes bool @@ -79,6 +80,25 @@ type EventSummary struct { QuotaDelta float64 `json:"quota_delta"` } +type RequestQuality struct { + RequestCount int64 `json:"request_count"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + SuccessRate float64 `json:"success_rate"` + AvgLatencyMS float64 `json:"avg_latency_ms"` + P95LatencyMS float64 `json:"p95_latency_ms"` + P99LatencyMS float64 `json:"p99_latency_ms"` + MinLatencyMS float64 `json:"min_latency_ms"` + MaxLatencyMS float64 `json:"max_latency_ms"` +} + +type EventPage struct { + Items []Event `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + type TrendPoint struct { Bucket time.Time `json:"bucket"` RequestCount int64 `json:"request_count"` diff --git a/docs/testing.md b/docs/testing.md index e9f2e44..68bcb82 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -56,6 +56,9 @@ Minimal checks: 3. Send one stream request to `POST /ai-router/api/responses` and verify the stream terminates with an upstream-aligned terminal event 4. Switch between two official `auth.json` accounts and verify requests do not hang or lose terminal output 5. From Codex CLI, run one short prompt and verify the router account list shows a run against the active account +6. Verify stats quality and pagination APIs: + - `curl "http://127.0.0.1:6789/ai-router/api/dashboard/request-quality?range=24h"` + - `curl "http://127.0.0.1:6789/ai-router/api/dashboard/recent-events?range=24h&page=1&page_size=20"` Thin gateway notes: diff --git a/frontend/src/features/stats/StatsPage.test.tsx b/frontend/src/features/stats/StatsPage.test.tsx index 11ef61e..8304087 100644 --- a/frontend/src/features/stats/StatsPage.test.tsx +++ b/frontend/src/features/stats/StatsPage.test.tsx @@ -4,6 +4,7 @@ import { StatsPage } from "./StatsPage"; vi.mock("../../lib/api", () => ({ listAccounts: vi.fn(), + getDashboardRequestQuality: vi.fn(), getDashboardSummary: vi.fn(), getDashboardTrends: vi.fn(), getDashboardModelDistribution: vi.fn(), @@ -11,7 +12,7 @@ vi.mock("../../lib/api", () => ({ listServerUsers: vi.fn(), })); -import { getDashboardModelDistribution, getDashboardRecentEvents, getDashboardSummary, getDashboardTrends, listAccounts, listServerUsers } from "../../lib/api"; +import { getDashboardModelDistribution, getDashboardRecentEvents, getDashboardRequestQuality, getDashboardSummary, getDashboardTrends, listAccounts, listServerUsers } from "../../lib/api"; describe("StatsPage", () => { const t = (value: string) => value; @@ -37,6 +38,17 @@ describe("StatsPage", () => { balance_delta: -4.5, quota_delta: -8000, } as never); + vi.mocked(getDashboardRequestQuality).mockResolvedValue({ + request_count: 12, + success_count: 10, + failure_count: 2, + success_rate: 0.8333, + avg_latency_ms: 321.4, + p95_latency_ms: 880, + p99_latency_ms: 1200, + min_latency_ms: 90, + max_latency_ms: 1500, + } as never); vi.mocked(getDashboardTrends).mockResolvedValue([ { bucket: "2026-03-15T09:00:00Z", @@ -49,22 +61,27 @@ describe("StatsPage", () => { quota_delta: 0, }, ] as never); - vi.mocked(getDashboardRecentEvents).mockResolvedValue([ - { - id: 1, - account_id: 1, - provider_type: "openai-compatible", - request_kind: "responses", - model: "gpt-5.2", - status: "completed", - input_tokens: 1200, - output_tokens: 300, - total_tokens: 1500, - estimated_cost: 0.42, - latency_ms: 321, - created_at: "2026-03-15T10:05:00Z", - }, - ] as never); + vi.mocked(getDashboardRecentEvents).mockResolvedValue({ + items: [ + { + id: 1, + account_id: 1, + provider_type: "openai-compatible", + request_kind: "responses", + model: "gpt-5.2", + status: "completed", + input_tokens: 1200, + output_tokens: 300, + total_tokens: 1500, + estimated_cost: 0.42, + latency_ms: 321, + created_at: "2026-03-15T10:05:00Z", + }, + ], + total: 31, + page: 1, + page_size: 20, + } as never); vi.mocked(getDashboardModelDistribution).mockResolvedValue([ { model: "gpt-5.2", request_count: 9 }, { model: "gpt-5.4", request_count: 3 }, @@ -79,6 +96,12 @@ describe("StatsPage", () => { expect(screen.getByText("输入 Token")).toBeInTheDocument(); expect(screen.getByText("输出 Token")).toBeInTheDocument(); expect(screen.getByText("预估费用")).toBeInTheDocument(); + expect(screen.queryByText("综合成功率")).not.toBeInTheDocument(); + expect(screen.queryByText("83.3%")).not.toBeInTheDocument(); + expect(screen.getByText("平均延迟")).toBeInTheDocument(); + expect(screen.getByText("P95 延迟")).toBeInTheDocument(); + expect(screen.getByText("P99 延迟")).toBeInTheDocument(); + expect(screen.getByText("最大 / 最小延迟")).toBeInTheDocument(); expect(screen.queryByText("余额变化")).not.toBeInTheDocument(); expect(screen.getByText("US$1.23")).toBeInTheDocument(); expect(screen.queryByText("总 Token")).not.toBeInTheDocument(); @@ -93,9 +116,10 @@ describe("StatsPage", () => { await waitFor(() => { expect(getDashboardSummary).toHaveBeenCalledWith("24h", undefined, "", undefined); + expect(getDashboardRequestQuality).toHaveBeenCalledWith("24h", undefined, "", undefined); expect(getDashboardTrends).toHaveBeenCalledWith("24h", undefined, "", undefined); expect(getDashboardModelDistribution).toHaveBeenCalledWith("24h", undefined, "", undefined); - expect(getDashboardRecentEvents).toHaveBeenCalledWith("24h", undefined, "", 20, undefined); + expect(getDashboardRecentEvents).toHaveBeenCalledWith("24h", undefined, "", 1, 20, undefined); }); }); @@ -125,9 +149,10 @@ describe("StatsPage", () => { await waitFor(() => { expect(getDashboardSummary).toHaveBeenLastCalledWith("24h", undefined, "", 11); + expect(getDashboardRequestQuality).toHaveBeenLastCalledWith("24h", undefined, "", 11); expect(getDashboardTrends).toHaveBeenLastCalledWith("24h", undefined, "", 11); expect(getDashboardModelDistribution).toHaveBeenLastCalledWith("24h", undefined, "", 11); - expect(getDashboardRecentEvents).toHaveBeenLastCalledWith("24h", undefined, "", 20, 11); + expect(getDashboardRecentEvents).toHaveBeenLastCalledWith("24h", undefined, "", 1, 20, 11); }); }); @@ -138,9 +163,21 @@ describe("StatsPage", () => { await waitFor(() => { expect(getDashboardSummary).toHaveBeenLastCalledWith("1y", undefined, "", undefined); + expect(getDashboardRequestQuality).toHaveBeenLastCalledWith("1y", undefined, "", undefined); expect(getDashboardTrends).toHaveBeenLastCalledWith("1y", undefined, "", undefined); expect(getDashboardModelDistribution).toHaveBeenLastCalledWith("1y", undefined, "", undefined); - expect(getDashboardRecentEvents).toHaveBeenLastCalledWith("1y", undefined, "", 20, undefined); + expect(getDashboardRecentEvents).toHaveBeenLastCalledWith("1y", undefined, "", 1, 20, undefined); + }); + }); + + it("loads another recent events page through pagination", async () => { + render(); + + expect(await screen.findByText("最近记录")).toBeInTheDocument(); + fireEvent.click(screen.getByTitle("2")); + + await waitFor(() => { + expect(getDashboardRecentEvents).toHaveBeenLastCalledWith("24h", undefined, "", 2, 20, undefined); }); }); }); diff --git a/frontend/src/features/stats/StatsPage.tsx b/frontend/src/features/stats/StatsPage.tsx index 144df51..ba1e9af 100644 --- a/frontend/src/features/stats/StatsPage.tsx +++ b/frontend/src/features/stats/StatsPage.tsx @@ -1,4 +1,4 @@ -import { Card, Empty, Input, Select, Segmented, Spin, Tag } from "antd"; +import { Card, Empty, Input, Pagination, Select, Segmented, Spin, Tag } from "antd"; import { useEffect, useMemo, useRef, useState } from "react"; import { @@ -7,10 +7,13 @@ import { type UsageDashboardSummary, type UsageModelDistributionPoint, type UsageEventRecord, + type UsageEventPage, + type UsageRequestQuality, type UsageTrendPoint, type ServerUser, getDashboardModelDistribution, getDashboardRecentEvents, + getDashboardRequestQuality, getDashboardSummary, getDashboardTrends, listAccounts, @@ -47,6 +50,10 @@ function formatCurrency(language: AppLanguage, value: number): string { }).format(value); } +function formatLatency(language: AppLanguage, value: number): string { + return `${new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(value)} ms`; +} + function eventStatusColor(status: string): string { if (status === "completed") { return "success"; @@ -67,14 +74,26 @@ export function StatsPage({ language, t, serverMode = false }: StatsPageProps) { const [accounts, setAccounts] = useState([]); const [serverUsers, setServerUsers] = useState([]); const [summary, setSummary] = useState(null); + const [requestQuality, setRequestQuality] = useState(null); const [trends, setTrends] = useState([]); const [modelDistribution, setModelDistribution] = useState([]); const [events, setEvents] = useState([]); + const [eventsPage, setEventsPage] = useState>({ + total: 0, + page: 1, + page_size: 20, + }); const [error, setError] = useState(null); const hasLoadedRef = useRef(false); + const recentPageSize = 20; + + useEffect(() => { + setEventsPage((current) => (current.page === 1 ? current : { ...current, page: 1 })); + }, [accountID, model, rangeHours, serverUserID]); useEffect(() => { let disposed = false; + const requestedPage = eventsPage.page; async function load() { if (hasLoadedRef.current) { @@ -84,13 +103,14 @@ export function StatsPage({ language, t, serverMode = false }: StatsPageProps) { } setError(null); try { - const [accountList, serverUserList, nextSummary, nextTrends, nextModelDistribution, nextEvents] = await Promise.all([ + const [accountList, serverUserList, nextSummary, nextQuality, nextTrends, nextModelDistribution, nextEventsPage] = await Promise.all([ listAccounts(), serverMode ? listServerUsers() : Promise.resolve([]), getDashboardSummary(rangeHours, accountID, model, serverUserID), + getDashboardRequestQuality(rangeHours, accountID, model, serverUserID), getDashboardTrends(rangeHours, accountID, model, serverUserID), getDashboardModelDistribution(rangeHours, accountID, model, serverUserID), - getDashboardRecentEvents(rangeHours, accountID, model, 20, serverUserID), + getDashboardRecentEvents(rangeHours, accountID, model, requestedPage, recentPageSize, serverUserID), ]); if (disposed) { return; @@ -98,9 +118,15 @@ export function StatsPage({ language, t, serverMode = false }: StatsPageProps) { setAccounts(Array.isArray(accountList) ? accountList : []); setServerUsers(Array.isArray(serverUserList) ? serverUserList : []); setSummary(nextSummary); + setRequestQuality(nextQuality); setTrends(Array.isArray(nextTrends) ? nextTrends : []); setModelDistribution(Array.isArray(nextModelDistribution) ? nextModelDistribution : []); - setEvents(Array.isArray(nextEvents) ? nextEvents : []); + setEvents(Array.isArray(nextEventsPage?.items) ? nextEventsPage.items : []); + setEventsPage({ + total: nextEventsPage?.total ?? 0, + page: nextEventsPage?.page ?? requestedPage, + page_size: nextEventsPage?.page_size ?? recentPageSize, + }); hasLoadedRef.current = true; } catch (loadError) { if (!disposed) { @@ -118,7 +144,7 @@ export function StatsPage({ language, t, serverMode = false }: StatsPageProps) { return () => { disposed = true; }; - }, [accountID, model, rangeHours, serverMode, serverUserID, t]); + }, [accountID, eventsPage.page, model, rangeHours, serverMode, serverUserID, t]); const accountNameByID = useMemo(() => { return new Map(accounts.map((account) => [account.id, account.account_name])); @@ -151,6 +177,29 @@ export function StatsPage({ language, t, serverMode = false }: StatsPageProps) { }, ]; + const qualityCards = [ + { + label: t("平均延迟"), + value: requestQuality ? formatLatency(language, requestQuality.avg_latency_ms) : "--", + hint: requestQuality ? `${formatCompactNumber(language, requestQuality.request_count)} ${t("次请求")}` : "--", + }, + { + label: t("P95 延迟"), + value: requestQuality ? formatLatency(language, requestQuality.p95_latency_ms) : "--", + hint: t("95% 请求不超过该值"), + }, + { + label: t("P99 延迟"), + value: requestQuality ? formatLatency(language, requestQuality.p99_latency_ms) : "--", + hint: t("99% 请求不超过该值"), + }, + { + label: t("最大 / 最小延迟"), + value: requestQuality ? `${formatLatency(language, requestQuality.max_latency_ms)} / ${formatLatency(language, requestQuality.min_latency_ms)}` : "--", + hint: t("筛选范围内的极值"), + }, + ]; + return (
@@ -219,6 +268,16 @@ export function StatsPage({ language, t, serverMode = false }: StatsPageProps) { ))}
+
+ {qualityCards.map((card) => ( + +
{card.label}
+
{card.value}
+
{card.hint}
+
+ ))} +
+
{trends.length === 0 ? ( @@ -241,26 +300,39 @@ export function StatsPage({ language, t, serverMode = false }: StatsPageProps) { {events.length === 0 ? ( ) : ( -
- {events.map((event) => ( -
-
-
- {event.model} - {event.status} +
+
+ {events.map((event) => ( +
+
+
+ {event.model} + {event.status} +
+
+ {`${accountNameByID.get(event.account_id) ?? t("账户")} · #${event.account_id}`} + {new Date(event.created_at).toLocaleString(language, { hour12: false })} + {Math.round(event.latency_ms)} ms +
-
- {`${accountNameByID.get(event.account_id) ?? t("账户")} · #${event.account_id}`} - {new Date(event.created_at).toLocaleString(language, { hour12: false })} - {Math.round(event.latency_ms)} ms +
+ {formatCompactNumber(language, event.total_tokens)} tok + {formatCurrency(language, event.estimated_cost)}
-
- {formatCompactNumber(language, event.total_tokens)} tok - {formatCurrency(language, event.estimated_cost)} -
-
- ))} + ))} +
+ {eventsPage.total > eventsPage.page_size ? ( + setEventsPage((current) => ({ ...current, page }))} + /> + ) : null}
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1cf6fc1..f03e7f5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -111,6 +111,18 @@ export type UsageDashboardSummary = { quota_delta: number; }; +export type UsageRequestQuality = { + request_count: number; + success_count: number; + failure_count: number; + success_rate: number; + avg_latency_ms: number; + p95_latency_ms: number; + p99_latency_ms: number; + min_latency_ms: number; + max_latency_ms: number; +}; + export type ServerSession = { authenticated: boolean; role?: "admin" | "user"; @@ -242,6 +254,13 @@ export type UsageEventRecord = { created_at: string; }; +export type UsageEventPage = { + items: UsageEventRecord[]; + total: number; + page: number; + page_size: number; +}; + export type PricingRule = { input_per_million: number; output_per_million: number; @@ -706,6 +725,8 @@ function dashboardQuery( model?: string, limit?: number, serverUserID?: number, + page?: number, + pageSize?: number, ): string { const params = new URLSearchParams(); params.set("range", range); @@ -721,6 +742,12 @@ function dashboardQuery( if (limit && limit > 0) { params.set("limit", String(limit)); } + if (page && page > 0) { + params.set("page", String(page)); + } + if (pageSize && pageSize > 0) { + params.set("page_size", String(pageSize)); + } return params.toString(); } @@ -739,6 +766,21 @@ export async function getDashboardSummary( return response.json(); } +export async function getDashboardRequestQuality( + range: DashboardRangeKey = "24h", + accountID?: number, + model?: string, + serverUserID?: number, +): Promise { + const response = await fetch( + apiPath(`/dashboard/request-quality?${dashboardQuery(range, accountID, model, undefined, serverUserID)}`), + ); + if (!response.ok) { + throw new Error("failed to load dashboard request quality"); + } + return response.json(); +} + export async function getDashboardTrends( range: DashboardRangeKey = "24h", accountID?: number, @@ -758,18 +800,28 @@ export async function getDashboardRecentEvents( range: DashboardRangeKey = "24h", accountID?: number, model?: string, - limit = 20, + page = 1, + pageSize = 20, serverUserID?: number, -): Promise { +): Promise { const response = await fetch( apiPath( - `/dashboard/recent-events?${dashboardQuery(range, accountID, model, limit, serverUserID)}`, + `/dashboard/recent-events?${dashboardQuery(range, accountID, model, undefined, serverUserID, page, pageSize)}`, ), ); if (!response.ok) { throw new Error("failed to load recent usage events"); } - return response.json(); + const payload = await response.json(); + if (Array.isArray(payload)) { + return { + items: payload, + total: payload.length, + page, + page_size: pageSize, + }; + } + return payload; } export async function getDashboardModelDistribution( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index acdeec1..0514924 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1650,6 +1650,10 @@ html[data-theme-mode="dark"] .top-home-update-dot { gap: 12px; } +.stats-quality-grid { + grid-template-columns: repeat(auto-fit, minmax(188px, 1fr)); +} + .stats-summary-card, .stats-panel { border-radius: 16px; @@ -1706,6 +1710,10 @@ html[data-theme-mode="dark"] .top-home-update-dot { color: var(--text-secondary); } +.stats-quality-card .stats-card-value { + font-size: 20px; +} + .stats-content-grid { display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(280px, 0.9fr); @@ -1718,6 +1726,16 @@ html[data-theme-mode="dark"] .top-home-update-dot { gap: 12px; } +.stats-events-block { + display: flex; + flex-direction: column; + gap: 14px; +} + +.stats-events-pagination { + align-self: flex-end; +} + .stats-event-meta, .stats-event-metrics { display: flex; From 9e6a57c3a45f929bda3978ed69f3e89c4c43ae5b Mon Sep 17 00:00:00 2001 From: GcsSloop Date: Wed, 24 Jun 2026 20:23:39 +0800 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=E5=90=8C=E6=AD=A5=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E7=89=88=E6=9C=AC=201.6.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/package-lock.json | 4 ++-- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 603b741..857d75a 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "aigate-desktop", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aigate-desktop", - "version": "1.6.0", + "version": "1.6.1", "devDependencies": { "@tauri-apps/cli": "^2.8.0" } diff --git a/desktop/package.json b/desktop/package.json index 1c61553..f97a16a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "aigate-desktop", "private": true, - "version": "1.6.0", + "version": "1.6.1", "type": "module", "scripts": { "dev": "tauri dev", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index a8f3e7a..56502b4 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "aigate-desktop" -version = "1.6.0" +version = "1.6.1" dependencies = [ "arboard", "base64 0.22.1", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index ae72a3b..01f72a9 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aigate-desktop" -version = "1.6.0" +version = "1.6.1" edition = "2021" [build-dependencies] diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index a40c5c1..180d71e 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "AI Gate", - "version": "1.6.0", + "version": "1.6.1", "identifier": "com.aigate.desktop", "build": { "frontendDist": "../../frontend/dist", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1c45c6e..3befb9c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "aigate-frontend", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aigate-frontend", - "version": "1.6.0", + "version": "1.6.1", "dependencies": { "@ant-design/icons": "^6.1.0", "@dnd-kit/core": "^6.3.1", diff --git a/frontend/package.json b/frontend/package.json index e92ade0..a2d7bb8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aigate-frontend", "private": true, - "version": "1.6.0", + "version": "1.6.1", "type": "module", "scripts": { "dev": "vite",