Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions backend/internal/api/dashboard_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
77 changes: 64 additions & 13 deletions backend/internal/api/dashboard_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions backend/internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions backend/internal/bootstrap/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
103 changes: 103 additions & 0 deletions backend/internal/usage/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package usage
import (
"database/sql"
"fmt"
"math"
"sort"
"time"
)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading