diff --git a/cmd/gateway.go b/cmd/gateway.go index f68963e2..c4ed8d32 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -291,6 +291,23 @@ func runGateway() { initOTelExporter(context.Background(), cfg, traceCollector) } + // Start snapshot worker for hourly usage aggregation + if pgStores.Snapshots != nil { + snapshotWorker := tracing.NewSnapshotWorker(pgStores.DB, pgStores.Snapshots) + snapshotWorker.Start() + defer snapshotWorker.Stop() + + // Backfill historical data in background + go func() { + count, err := snapshotWorker.Backfill(context.Background()) + if err != nil { + slog.Warn("snapshot backfill failed", "error", err) + } else if count > 0 { + slog.Info("snapshot backfill complete", "hours", count) + } + }() + } + // Redis cache: compiled via build tags. Build with 'go build -tags redis' to enable. redisClient := initRedisClient(cfg) defer shutdownRedis(redisClient) @@ -638,6 +655,11 @@ func runGateway() { server.SetActivityHandler(httpapi.NewActivityHandler(pgStores.Activity, cfg.Gateway.Token)) } + // Usage analytics API + if pgStores.Snapshots != nil { + server.SetUsageHandler(httpapi.NewUsageHandler(pgStores.Snapshots, pgStores.DB, cfg.Gateway.Token)) + } + // Memory management API (wired directly, only needs MemoryStore + token) if pgStores != nil && pgStores.Memory != nil { server.SetMemoryHandler(httpapi.NewMemoryHandler(pgStores.Memory, cfg.Gateway.Token)) diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 37b1f6f4..bbef8c53 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -56,6 +56,7 @@ type Server struct { mediaUploadHandler *httpapi.MediaUploadHandler // media upload endpoint mediaServeHandler *httpapi.MediaServeHandler // media serve endpoint activityHandler *httpapi.ActivityHandler // activity audit log API + usageHandler *httpapi.UsageHandler // usage analytics API agentStore store.AgentStore // for context injection in tools_invoke msgBus *bus.MessageBus // for MCP bridge media delivery @@ -251,6 +252,10 @@ func (s *Server) BuildMux() *http.ServeMux { s.activityHandler.RegisterRoutes(mux) } + if s.usageHandler != nil { + s.usageHandler.RegisterRoutes(mux) + } + // OAuth endpoints (available in all modes) if s.oauthHandler != nil { s.oauthHandler.RegisterRoutes(mux) @@ -475,6 +480,9 @@ func (s *Server) SetKnowledgeGraphHandler(h *httpapi.KnowledgeGraphHandler) { s. // SetActivityHandler sets the activity audit log handler. func (s *Server) SetActivityHandler(h *httpapi.ActivityHandler) { s.activityHandler = h } +// SetUsageHandler sets the usage analytics handler. +func (s *Server) SetUsageHandler(h *httpapi.UsageHandler) { s.usageHandler = h } + // SetAgentStore sets the agent store for context injection in tools_invoke. func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as } diff --git a/internal/http/usage.go b/internal/http/usage.go new file mode 100644 index 00000000..c292b3c8 --- /dev/null +++ b/internal/http/usage.go @@ -0,0 +1,248 @@ +package http + +import ( + "database/sql" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// UsageHandler serves pre-computed usage analytics from snapshots. +type UsageHandler struct { + snapshots store.SnapshotStore + db *sql.DB + token string +} + +func NewUsageHandler(snapshots store.SnapshotStore, db *sql.DB, token string) *UsageHandler { + return &UsageHandler{snapshots: snapshots, db: db, token: token} +} + +func (h *UsageHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/usage/timeseries", h.authMiddleware(h.handleTimeSeries)) + mux.HandleFunc("GET /v1/usage/breakdown", h.authMiddleware(h.handleBreakdown)) + mux.HandleFunc("GET /v1/usage/summary", h.authMiddleware(h.handleSummary)) +} + +func (h *UsageHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if h.token != "" { + if extractBearerToken(r) != h.token { + locale := extractLocale(r) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)}) + return + } + } + next(w, r) + } +} + +func (h *UsageHandler) handleTimeSeries(w http.ResponseWriter, r *http.Request) { + q := parseSnapshotFilters(r) + if q.From.IsZero() || q.To.IsZero() { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "from and to are required"}) + return + } + if q.GroupBy == "" { + q.GroupBy = "hour" + } + + points, err := h.snapshots.GetTimeSeries(r.Context(), q) + if err != nil { + slog.Error("usage.timeseries query failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + return + } + + // Gap-fill: if "to" extends into current incomplete hour, query live traces + now := time.Now().UTC() + currentHourStart := now.Truncate(time.Hour) + if q.To.After(currentHourStart) && currentHourStart.After(q.From) { + livePoint := h.queryLiveHour(r, currentHourStart, now, q) + if livePoint != nil { + points = append(points, *livePoint) + } + } + + writeJSON(w, http.StatusOK, map[string]any{"points": points}) +} + +func (h *UsageHandler) handleBreakdown(w http.ResponseWriter, r *http.Request) { + q := parseSnapshotFilters(r) + if q.From.IsZero() || q.To.IsZero() { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "from and to are required"}) + return + } + if q.GroupBy == "" { + q.GroupBy = "provider" + } + + rows, err := h.snapshots.GetBreakdown(r.Context(), q) + if err != nil { + slog.Error("usage.breakdown query failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"rows": rows}) +} + +func (h *UsageHandler) handleSummary(w http.ResponseWriter, r *http.Request) { + period := r.URL.Query().Get("period") + if period == "" { + period = "24h" + } + + now := time.Now().UTC() + var currentFrom, previousFrom time.Time + + switch period { + case "today": + currentFrom = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + previousFrom = currentFrom.AddDate(0, 0, -1) + case "7d": + currentFrom = now.Add(-7 * 24 * time.Hour) + previousFrom = currentFrom.Add(-7 * 24 * time.Hour) + case "30d": + currentFrom = now.Add(-30 * 24 * time.Hour) + previousFrom = currentFrom.Add(-30 * 24 * time.Hour) + default: // "24h" + currentFrom = now.Add(-24 * time.Hour) + previousFrom = currentFrom.Add(-24 * time.Hour) + } + + baseQ := parseSnapshotFilters(r) + + // Current period + currentQ := baseQ + currentQ.From = currentFrom + currentQ.To = now + currentQ.GroupBy = "hour" + + // Previous period (same duration, shifted back) + previousQ := baseQ + previousQ.From = previousFrom + previousQ.To = currentFrom + previousQ.GroupBy = "hour" + + currentSummary := h.aggregateTimeSeries(r, currentQ) + previousSummary := h.aggregateTimeSeries(r, previousQ) + + writeJSON(w, http.StatusOK, map[string]any{ + "current": currentSummary, + "previous": previousSummary, + }) +} + +// usageSummary is the response shape for summary endpoint. +type usageSummary struct { + Requests int `json:"requests"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + Cost float64 `json:"cost"` + UniqueUsers int `json:"unique_users"` + Errors int `json:"errors"` + LLMCalls int `json:"llm_calls"` + ToolCalls int `json:"tool_calls"` + AvgDurationMS int `json:"avg_duration_ms"` +} + +func (h *UsageHandler) aggregateTimeSeries(r *http.Request, q store.SnapshotQuery) usageSummary { + points, err := h.snapshots.GetTimeSeries(r.Context(), q) + if err != nil { + return usageSummary{} + } + + var s usageSummary + var totalWeightedDuration int64 + for _, p := range points { + s.Requests += p.RequestCount + s.InputTokens += p.InputTokens + s.OutputTokens += p.OutputTokens + s.Cost += p.TotalCost + s.UniqueUsers += p.UniqueUsers + s.Errors += p.ErrorCount + s.LLMCalls += p.LLMCallCount + s.ToolCalls += p.ToolCallCount + totalWeightedDuration += int64(p.AvgDurationMS) * int64(p.RequestCount) + } + if s.Requests > 0 { + s.AvgDurationMS = int(totalWeightedDuration / int64(s.Requests)) + } + return s +} + +// queryLiveHour runs a lightweight query on traces for the current incomplete hour. +func (h *UsageHandler) queryLiveHour(r *http.Request, from, to time.Time, q store.SnapshotQuery) *store.SnapshotTimeSeries { + query := `SELECT + COUNT(*), + COUNT(*) FILTER (WHERE status = 'error'), + COUNT(DISTINCT user_id), + COALESCE(SUM(total_input_tokens), 0), + COALESCE(SUM(total_output_tokens), 0), + COALESCE(SUM(total_cost), 0), + COALESCE(SUM(llm_call_count), 0), + COALESCE(SUM(tool_call_count), 0), + COALESCE(AVG(duration_ms), 0)::INTEGER + FROM traces + WHERE start_time >= $1 AND start_time < $2 + AND parent_trace_id IS NULL` + + args := []any{from, to} + idx := 3 + if q.AgentID != nil { + query += fmt.Sprintf(" AND agent_id = $%d", idx) + args = append(args, *q.AgentID) + idx++ + } + if q.Channel != "" { + query += fmt.Sprintf(" AND channel = $%d", idx) + args = append(args, q.Channel) + idx++ + } + // Note: provider/model filters are not applied here because the traces table + // does not have provider/model columns (those live on spans). The live gap-fill + // is a rough approximation for the current incomplete hour. + + var p store.SnapshotTimeSeries + p.BucketTime = from + err := h.db.QueryRowContext(r.Context(), query, args...).Scan( + &p.RequestCount, &p.ErrorCount, &p.UniqueUsers, + &p.InputTokens, &p.OutputTokens, &p.TotalCost, + &p.LLMCallCount, &p.ToolCallCount, &p.AvgDurationMS, + ) + if err != nil || p.RequestCount == 0 { + return nil + } + return &p +} + +func parseSnapshotFilters(r *http.Request) store.SnapshotQuery { + q := store.SnapshotQuery{} + if v := r.URL.Query().Get("from"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + q.From = t + } + } + if v := r.URL.Query().Get("to"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + q.To = t + } + } + if v := r.URL.Query().Get("agent_id"); v != "" { + if id, err := uuid.Parse(v); err == nil { + q.AgentID = &id + } + } + q.Provider = r.URL.Query().Get("provider") + q.Model = r.URL.Query().Get("model") + q.Channel = r.URL.Query().Get("channel") + q.GroupBy = r.URL.Query().Get("group_by") + return q +} + diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index 2f839cfa..58b1ea0a 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -43,5 +43,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { KnowledgeGraph: NewPGKnowledgeGraphStore(db), Contacts: NewPGContactStore(db), Activity: NewPGActivityStore(db), + Snapshots: NewPGSnapshotStore(db), }, nil } diff --git a/internal/store/pg/snapshot.go b/internal/store/pg/snapshot.go new file mode 100644 index 00000000..e62847f1 --- /dev/null +++ b/internal/store/pg/snapshot.go @@ -0,0 +1,296 @@ +package pg + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGSnapshotStore implements store.SnapshotStore backed by Postgres. +type PGSnapshotStore struct { + db *sql.DB +} + +func NewPGSnapshotStore(db *sql.DB) *PGSnapshotStore { + return &PGSnapshotStore{db: db} +} + +const snapshotFieldCount = 21 + +// maxBatchRows limits each INSERT to stay under PG's 65535 param limit (65535 / 21 ≈ 3120). +const maxBatchRows = 3000 + +func (s *PGSnapshotStore) UpsertSnapshots(ctx context.Context, snapshots []store.UsageSnapshot) error { + if len(snapshots) == 0 { + return nil + } + for start := 0; start < len(snapshots); start += maxBatchRows { + end := start + maxBatchRows + if end > len(snapshots) { + end = len(snapshots) + } + if err := s.upsertBatch(ctx, snapshots[start:end]); err != nil { + return err + } + } + return nil +} + +func (s *PGSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.UsageSnapshot) error { + var vals []string + var args []any + for i, snap := range snapshots { + base := i * snapshotFieldCount + placeholders := make([]string, snapshotFieldCount) + for j := range snapshotFieldCount { + placeholders[j] = fmt.Sprintf("$%d", base+j+1) + } + vals = append(vals, "("+strings.Join(placeholders, ", ")+")") + args = append(args, + snap.BucketHour, nilUUID(snap.AgentID), snap.Provider, snap.Model, snap.Channel, + snap.InputTokens, snap.OutputTokens, snap.CacheReadTokens, snap.CacheCreateTokens, snap.ThinkingTokens, + snap.TotalCost, snap.RequestCount, snap.LLMCallCount, snap.ToolCallCount, + snap.ErrorCount, snap.UniqueUsers, snap.AvgDurationMS, + snap.MemoryDocs, snap.MemoryChunks, snap.KGEntities, snap.KGRelations, + ) + } + + query := `INSERT INTO usage_snapshots ( + bucket_hour, agent_id, provider, model, channel, + input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, thinking_tokens, + total_cost, request_count, llm_call_count, tool_call_count, + error_count, unique_users, avg_duration_ms, + memory_docs, memory_chunks, kg_entities, kg_relations + ) VALUES ` + strings.Join(vals, ", ") + ` + ON CONFLICT (bucket_hour, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), provider, model, channel) + DO UPDATE SET + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + cache_create_tokens = EXCLUDED.cache_create_tokens, + thinking_tokens = EXCLUDED.thinking_tokens, + total_cost = EXCLUDED.total_cost, + request_count = EXCLUDED.request_count, + llm_call_count = EXCLUDED.llm_call_count, + tool_call_count = EXCLUDED.tool_call_count, + error_count = EXCLUDED.error_count, + unique_users = EXCLUDED.unique_users, + avg_duration_ms = EXCLUDED.avg_duration_ms, + memory_docs = EXCLUDED.memory_docs, + memory_chunks = EXCLUDED.memory_chunks, + kg_entities = EXCLUDED.kg_entities, + kg_relations = EXCLUDED.kg_relations` + + _, err := s.db.ExecContext(ctx, query, args...) + return err +} + +func (s *PGSnapshotStore) GetTimeSeries(ctx context.Context, q store.SnapshotQuery) ([]store.SnapshotTimeSeries, error) { + bucketExpr := "bucket_hour" + if q.GroupBy == "day" { + bucketExpr = "date_trunc('day', bucket_hour)" + } + + where, args := buildSnapshotWhere(q) + + query := fmt.Sprintf(`SELECT + bucket_time, + SUM(input_tokens), SUM(output_tokens), + SUM(cache_read_tokens), SUM(cache_create_tokens), SUM(thinking_tokens), + SUM(total_cost), + SUM(request_count), SUM(llm_call_count), SUM(tool_call_count), + SUM(error_count), SUM(unique_users), + CASE WHEN SUM(request_count) > 0 + THEN SUM(avg_duration_ms * request_count) / SUM(request_count) + ELSE 0 END, + SUM(memory_docs), SUM(memory_chunks), + SUM(kg_entities), SUM(kg_relations) + FROM ( + SELECT + %s as bucket_time, + CASE WHEN provider != '' THEN input_tokens ELSE 0 END as input_tokens, + CASE WHEN provider != '' THEN output_tokens ELSE 0 END as output_tokens, + CASE WHEN provider != '' THEN cache_read_tokens ELSE 0 END as cache_read_tokens, + CASE WHEN provider != '' THEN cache_create_tokens ELSE 0 END as cache_create_tokens, + CASE WHEN provider != '' THEN thinking_tokens ELSE 0 END as thinking_tokens, + CASE WHEN provider != '' THEN total_cost ELSE 0 END as total_cost, + CASE WHEN provider != '' THEN llm_call_count ELSE 0 END as llm_call_count, + CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END as request_count, + CASE WHEN provider = '' AND model = '' THEN tool_call_count ELSE 0 END as tool_call_count, + CASE WHEN provider = '' AND model = '' THEN error_count ELSE 0 END as error_count, + CASE WHEN provider = '' AND model = '' THEN unique_users ELSE 0 END as unique_users, + CASE WHEN provider = '' AND model = '' THEN avg_duration_ms ELSE 0 END as avg_duration_ms, + CASE WHEN provider = '' AND model = '' THEN memory_docs ELSE 0 END as memory_docs, + CASE WHEN provider = '' AND model = '' THEN memory_chunks ELSE 0 END as memory_chunks, + CASE WHEN provider = '' AND model = '' THEN kg_entities ELSE 0 END as kg_entities, + CASE WHEN provider = '' AND model = '' THEN kg_relations ELSE 0 END as kg_relations + FROM usage_snapshots + %s + ) sub + GROUP BY bucket_time + ORDER BY bucket_time`, bucketExpr, where) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("get timeseries: %w", err) + } + defer rows.Close() + + var result []store.SnapshotTimeSeries + for rows.Next() { + var ts store.SnapshotTimeSeries + if err := rows.Scan( + &ts.BucketTime, + &ts.InputTokens, &ts.OutputTokens, + &ts.CacheReadTokens, &ts.CacheCreateTokens, &ts.ThinkingTokens, + &ts.TotalCost, + &ts.RequestCount, &ts.LLMCallCount, &ts.ToolCallCount, + &ts.ErrorCount, &ts.UniqueUsers, &ts.AvgDurationMS, + &ts.MemoryDocs, &ts.MemoryChunks, + &ts.KGEntities, &ts.KGRelations, + ); err != nil { + return nil, fmt.Errorf("scan timeseries: %w", err) + } + result = append(result, ts) + } + return result, rows.Err() +} + +func (s *PGSnapshotStore) GetBreakdown(ctx context.Context, q store.SnapshotQuery) ([]store.SnapshotBreakdown, error) { + groupBy := q.GroupBy + if groupBy == "" { + groupBy = "provider" + } + + var groupCol, orderExpr, extraFilter string + switch groupBy { + case "provider": + groupCol = "provider" + orderExpr = "SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END) DESC" + extraFilter = " AND provider != '' AND model != ''" + case "model": + groupCol = "model" + orderExpr = "SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END) DESC" + extraFilter = " AND provider != '' AND model != ''" + case "channel": + groupCol = "channel" + orderExpr = "SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END) DESC" + extraFilter = " AND channel != ''" + case "agent": + groupCol = "agent_id::TEXT" + orderExpr = "SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END) DESC" + extraFilter = "" + default: + groupCol = "provider" + orderExpr = "SUM(input_tokens) DESC" + extraFilter = " AND provider != '' AND model != ''" + } + + where, args := buildSnapshotWhere(q) + if where == "" { + where = " WHERE 1=1" + } + where += extraFilter + + query := fmt.Sprintf(`SELECT + %s as key, + SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END), + SUM(CASE WHEN provider != '' THEN output_tokens ELSE 0 END), + SUM(CASE WHEN provider != '' THEN cache_read_tokens ELSE 0 END), + SUM(CASE WHEN provider != '' THEN cache_create_tokens ELSE 0 END), + SUM(CASE WHEN provider != '' THEN total_cost ELSE 0 END), + SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END), + SUM(CASE WHEN provider != '' THEN llm_call_count ELSE 0 END), + SUM(CASE WHEN provider = '' AND model = '' THEN tool_call_count ELSE 0 END), + SUM(CASE WHEN provider = '' AND model = '' THEN error_count ELSE 0 END), + CASE WHEN SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END) > 0 + THEN SUM(CASE WHEN provider = '' AND model = '' THEN avg_duration_ms * request_count ELSE 0 END) / + SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END) + ELSE 0 END + FROM usage_snapshots + %s + GROUP BY %s + ORDER BY %s`, groupCol, where, groupCol, orderExpr) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("get breakdown: %w", err) + } + defer rows.Close() + + var result []store.SnapshotBreakdown + for rows.Next() { + var b store.SnapshotBreakdown + if err := rows.Scan( + &b.Key, + &b.InputTokens, &b.OutputTokens, + &b.CacheReadTokens, &b.CacheCreateTokens, + &b.TotalCost, + &b.RequestCount, &b.LLMCallCount, &b.ToolCallCount, + &b.ErrorCount, &b.AvgDurationMS, + ); err != nil { + return nil, fmt.Errorf("scan breakdown: %w", err) + } + result = append(result, b) + } + return result, rows.Err() +} + +func (s *PGSnapshotStore) GetLatestBucket(ctx context.Context) (*time.Time, error) { + var t sql.NullTime + err := s.db.QueryRowContext(ctx, `SELECT MAX(bucket_hour) FROM usage_snapshots`).Scan(&t) + if err != nil { + return nil, fmt.Errorf("get latest bucket: %w", err) + } + if !t.Valid { + return nil, nil + } + return &t.Time, nil +} + +// buildSnapshotWhere builds a dynamic WHERE clause from SnapshotQuery filters. +func buildSnapshotWhere(q store.SnapshotQuery) (string, []any) { + var conds []string + var args []any + idx := 1 + + if !q.From.IsZero() { + conds = append(conds, fmt.Sprintf("bucket_hour >= $%d", idx)) + args = append(args, q.From) + idx++ + } + if !q.To.IsZero() { + conds = append(conds, fmt.Sprintf("bucket_hour < $%d", idx)) + args = append(args, q.To) + idx++ + } + if q.AgentID != nil { + conds = append(conds, fmt.Sprintf("agent_id = $%d", idx)) + args = append(args, *q.AgentID) + idx++ + } + if q.Provider != "" { + conds = append(conds, fmt.Sprintf("provider = $%d", idx)) + args = append(args, q.Provider) + idx++ + } + if q.Model != "" { + conds = append(conds, fmt.Sprintf("model = $%d", idx)) + args = append(args, q.Model) + idx++ + } + if q.Channel != "" { + conds = append(conds, fmt.Sprintf("channel = $%d", idx)) + args = append(args, q.Channel) + idx++ + } + + if len(conds) == 0 { + return "", nil + } + return " WHERE " + strings.Join(conds, " AND "), args +} diff --git a/internal/store/snapshot_store.go b/internal/store/snapshot_store.go new file mode 100644 index 00000000..cca54f0a --- /dev/null +++ b/internal/store/snapshot_store.go @@ -0,0 +1,97 @@ +package store + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// UsageSnapshot represents one hourly aggregation row. +type UsageSnapshot struct { + ID uuid.UUID `json:"id"` + BucketHour time.Time `json:"bucket_hour"` + AgentID *uuid.UUID `json:"agent_id,omitempty"` + Provider string `json:"provider"` + Model string `json:"model"` + Channel string `json:"channel"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheCreateTokens int64 `json:"cache_create_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCost float64 `json:"total_cost"` + RequestCount int `json:"request_count"` + LLMCallCount int `json:"llm_call_count"` + ToolCallCount int `json:"tool_call_count"` + ErrorCount int `json:"error_count"` + UniqueUsers int `json:"unique_users"` + AvgDurationMS int `json:"avg_duration_ms"` + MemoryDocs int `json:"memory_docs"` + MemoryChunks int `json:"memory_chunks"` + KGEntities int `json:"kg_entities"` + KGRelations int `json:"kg_relations"` + CreatedAt time.Time `json:"created_at"` +} + +// SnapshotQuery filters for listing snapshots. +type SnapshotQuery struct { + From time.Time // required: start of time range (inclusive) + To time.Time // required: end of time range (exclusive) + AgentID *uuid.UUID // optional: filter by agent + Provider string // optional: cross-filter by provider + Model string // optional: cross-filter by model + Channel string // optional: cross-filter by channel + GroupBy string // "hour" (default), "day", "provider", "model", "channel", "agent" +} + +// SnapshotTimeSeries is a single point in a time series response. +type SnapshotTimeSeries struct { + BucketTime time.Time `json:"bucket_time"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheCreateTokens int64 `json:"cache_create_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCost float64 `json:"total_cost"` + RequestCount int `json:"request_count"` + LLMCallCount int `json:"llm_call_count"` + ToolCallCount int `json:"tool_call_count"` + ErrorCount int `json:"error_count"` + UniqueUsers int `json:"unique_users"` + AvgDurationMS int `json:"avg_duration_ms"` + MemoryDocs int `json:"memory_docs"` + MemoryChunks int `json:"memory_chunks"` + KGEntities int `json:"kg_entities"` + KGRelations int `json:"kg_relations"` +} + +// SnapshotBreakdown is a grouped aggregation row (by provider, model, etc.). +type SnapshotBreakdown struct { + Key string `json:"key"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheCreateTokens int64 `json:"cache_create_tokens"` + TotalCost float64 `json:"total_cost"` + RequestCount int `json:"request_count"` + LLMCallCount int `json:"llm_call_count"` + ToolCallCount int `json:"tool_call_count"` + ErrorCount int `json:"error_count"` + AvgDurationMS int `json:"avg_duration_ms"` +} + +// SnapshotStore manages pre-computed usage snapshots. +type SnapshotStore interface { + // UpsertSnapshots inserts or updates (on conflict, replace) a batch of snapshots. + UpsertSnapshots(ctx context.Context, snapshots []UsageSnapshot) error + + // GetTimeSeries returns hourly (or daily) aggregated time series. + GetTimeSeries(ctx context.Context, q SnapshotQuery) ([]SnapshotTimeSeries, error) + + // GetBreakdown returns aggregated data grouped by a dimension (provider, model, channel, agent). + GetBreakdown(ctx context.Context, q SnapshotQuery) ([]SnapshotBreakdown, error) + + // GetLatestBucket returns the most recent bucket_hour, used by worker to know where to resume. + GetLatestBucket(ctx context.Context) (*time.Time, error) +} diff --git a/internal/store/stores.go b/internal/store/stores.go index b0447958..f89abbe9 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -24,4 +24,5 @@ type Stores struct { KnowledgeGraph KnowledgeGraphStore Contacts ContactStore Activity ActivityStore + Snapshots SnapshotStore } diff --git a/internal/tracing/snapshot_worker.go b/internal/tracing/snapshot_worker.go new file mode 100644 index 00000000..79519b11 --- /dev/null +++ b/internal/tracing/snapshot_worker.go @@ -0,0 +1,487 @@ +package tracing + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SnapshotWorker periodically aggregates trace/span data into usage_snapshots. +type SnapshotWorker struct { + db *sql.DB + snapshots store.SnapshotStore + stopCh chan struct{} + wg sync.WaitGroup +} + +func NewSnapshotWorker(db *sql.DB, snapshots store.SnapshotStore) *SnapshotWorker { + return &SnapshotWorker{ + db: db, + snapshots: snapshots, + stopCh: make(chan struct{}), + } +} + +// Start launches the background aggregation loop. +func (w *SnapshotWorker) Start() { + w.wg.Add(1) + go w.loop() + slog.Info("snapshot worker started") +} + +// Stop signals the worker to stop and waits for completion. +func (w *SnapshotWorker) Stop() { + close(w.stopCh) + w.wg.Wait() + slog.Info("snapshot worker stopped") +} + +func (w *SnapshotWorker) loop() { + defer w.wg.Done() + + // On startup, catch up any missed hours + w.catchUp() + + // Tick at HH:05:00 UTC (5 min past the hour) + now := time.Now().UTC() + nextTick := now.Truncate(time.Hour).Add(time.Hour).Add(5 * time.Minute) + if now.After(nextTick) { + nextTick = nextTick.Add(time.Hour) + } + timer := time.NewTimer(time.Until(nextTick)) + defer timer.Stop() + + for { + select { + case <-w.stopCh: + return + case <-timer.C: + w.catchUp() + // Reset for next hour + nextTick = nextTick.Add(time.Hour) + timer.Reset(time.Until(nextTick)) + } + } +} + +// catchUp computes snapshots for all missed hours between latest bucket and current hour. +func (w *SnapshotWorker) catchUp() { + ctx := context.Background() + now := time.Now().UTC() + targetHour := now.Truncate(time.Hour).Add(-time.Hour) // previous complete hour + + latest, err := w.snapshots.GetLatestBucket(ctx) + if err != nil { + slog.Warn("snapshot: get latest bucket", "error", err) + return + } + + var startHour time.Time + if latest == nil { + // No snapshots yet — only compute the previous hour (backfill handles history) + startHour = targetHour + } else { + startHour = latest.Add(time.Hour) + } + + for h := startHour; !h.After(targetHour); h = h.Add(time.Hour) { + start := time.Now() + if err := w.aggregateHour(ctx, h); err != nil { + slog.Warn("snapshot: aggregate hour failed", "hour", h.Format(time.RFC3339), "error", err) + return // stop catch-up on error, will retry next tick + } + slog.Info("snapshot computed", "hour", h.Format(time.RFC3339), "duration_ms", time.Since(start).Milliseconds()) + } +} + +// Backfill populates usage_snapshots from historical trace/span data. +// Returns the number of hours processed. +func (w *SnapshotWorker) Backfill(ctx context.Context) (int, error) { + latest, err := w.snapshots.GetLatestBucket(ctx) + if err != nil { + return 0, fmt.Errorf("get latest bucket: %w", err) + } + + // Find earliest root trace + var earliest sql.NullTime + err = w.db.QueryRowContext(ctx, + `SELECT MIN(start_time) FROM traces WHERE parent_trace_id IS NULL`, + ).Scan(&earliest) + if err != nil || !earliest.Valid { + return 0, nil // no traces to backfill + } + + startHour := earliest.Time.UTC().Truncate(time.Hour) + if latest != nil { + startHour = latest.Add(time.Hour) + } + + endHour := time.Now().UTC().Truncate(time.Hour) + count := 0 + for h := startHour; h.Before(endHour); h = h.Add(time.Hour) { + if err := w.aggregateHour(ctx, h); err != nil { + slog.Warn("backfill: aggregate hour failed", "hour", h.Format(time.RFC3339), "error", err) + continue + } + count++ + } + return count, nil +} + +func (w *SnapshotWorker) aggregateHour(ctx context.Context, bucketStart time.Time) error { + bucketEnd := bucketStart.Add(time.Hour) + + // Query 1: trace-level metrics by (agent_id, channel) + traceRows, err := queryTraceAggregates(ctx, w.db, bucketStart, bucketEnd) + if err != nil { + return fmt.Errorf("trace aggregates: %w", err) + } + + // Query 2: span-level metrics by (agent_id, channel, provider, model) + spanRows, err := querySpanAggregates(ctx, w.db, bucketStart, bucketEnd) + if err != nil { + return fmt.Errorf("span aggregates: %w", err) + } + + // Memory & KG point-in-time counts + memoryCounts, err := queryMemoryCounts(ctx, w.db) + if err != nil { + slog.Warn("snapshot: memory counts failed, continuing without", "error", err) + memoryCounts = nil + } + kgCounts, err := queryKGCounts(ctx, w.db) + if err != nil { + slog.Warn("snapshot: kg counts failed, continuing without", "error", err) + kgCounts = nil + } + + // Merge into UsageSnapshot rows + snapshots := mergeTraceAndSpanRows(bucketStart, traceRows, spanRows, memoryCounts, kgCounts) + + if len(snapshots) == 0 { + return nil + } + + return w.snapshots.UpsertSnapshots(ctx, snapshots) +} + +// traceAggregate holds trace-level metrics for one (agent_id, channel) group. +type traceAggregate struct { + AgentID *uuid.UUID + Channel string + RequestCount int + ErrorCount int + UniqueUsers int + InputTokens int64 + OutputTokens int64 + TotalCost float64 + ToolCallCount int + AvgDurationMS int +} + +func queryTraceAggregates(ctx context.Context, db *sql.DB, from, to time.Time) ([]traceAggregate, error) { + rows, err := db.QueryContext(ctx, ` + SELECT + agent_id, + COALESCE(channel, '') as channel, + COUNT(*) as request_count, + COUNT(*) FILTER (WHERE status = 'error') as error_count, + COUNT(DISTINCT user_id) as unique_users, + COALESCE(SUM(total_input_tokens), 0) as input_tokens, + COALESCE(SUM(total_output_tokens), 0) as output_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(tool_call_count), 0) as tool_call_count, + COALESCE(AVG(duration_ms), 0)::INTEGER as avg_duration_ms + FROM traces + WHERE start_time >= $1 AND start_time < $2 + AND parent_trace_id IS NULL + GROUP BY agent_id, channel`, from, to) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []traceAggregate + for rows.Next() { + var ta traceAggregate + if err := rows.Scan( + &ta.AgentID, &ta.Channel, + &ta.RequestCount, &ta.ErrorCount, &ta.UniqueUsers, + &ta.InputTokens, &ta.OutputTokens, &ta.TotalCost, + &ta.ToolCallCount, &ta.AvgDurationMS, + ); err != nil { + return nil, err + } + result = append(result, ta) + } + return result, rows.Err() +} + +// spanAggregate holds span-level LLM metrics for one (agent_id, channel, provider, model) group. +type spanAggregate struct { + AgentID *uuid.UUID + Channel string + Provider string + Model string + LLMCallCount int + InputTokens int64 + OutputTokens int64 + TotalCost float64 + CacheReadTokens int64 + CacheCreateTokens int64 + ThinkingTokens int64 +} + +func querySpanAggregates(ctx context.Context, db *sql.DB, from, to time.Time) ([]spanAggregate, error) { + rows, err := db.QueryContext(ctx, ` + SELECT + t.agent_id, + COALESCE(t.channel, '') as channel, + COALESCE(s.provider, '') as provider, + COALESCE(s.model, '') as model, + COUNT(*) as llm_call_count, + COALESCE(SUM(s.input_tokens), 0) as span_input_tokens, + COALESCE(SUM(s.output_tokens), 0) as span_output_tokens, + COALESCE(SUM(s.total_cost), 0) as span_cost, + COALESCE(SUM((s.metadata->>'cache_read_tokens')::BIGINT), 0) as cache_read_tokens, + COALESCE(SUM((s.metadata->>'cache_creation_tokens')::BIGINT), 0) as cache_create_tokens, + COALESCE(SUM((s.metadata->>'thinking_tokens')::BIGINT), 0) as thinking_tokens + FROM traces t + JOIN spans s ON s.trace_id = t.id AND s.span_type = 'llm_call' + WHERE t.start_time >= $1 AND t.start_time < $2 + AND t.parent_trace_id IS NULL + GROUP BY t.agent_id, t.channel, s.provider, s.model`, from, to) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []spanAggregate + for rows.Next() { + var sa spanAggregate + if err := rows.Scan( + &sa.AgentID, &sa.Channel, + &sa.Provider, &sa.Model, + &sa.LLMCallCount, + &sa.InputTokens, &sa.OutputTokens, &sa.TotalCost, + &sa.CacheReadTokens, &sa.CacheCreateTokens, &sa.ThinkingTokens, + ); err != nil { + return nil, err + } + result = append(result, sa) + } + return result, rows.Err() +} + +// agentMemoryCounts holds point-in-time memory counts for one agent. +type agentMemoryCounts struct { + AgentID uuid.UUID + Docs int + Chunks int +} + +// agentKGCounts holds point-in-time KG counts for one agent. +type agentKGCounts struct { + AgentID uuid.UUID + Entities int + Relations int +} + +func queryMemoryCounts(ctx context.Context, db *sql.DB) (map[uuid.UUID]agentMemoryCounts, error) { + result := make(map[uuid.UUID]agentMemoryCounts) + + // Document counts + rows, err := db.QueryContext(ctx, `SELECT agent_id, COUNT(*) FROM memory_documents GROUP BY agent_id`) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var agentID uuid.UUID + var count int + if err := rows.Scan(&agentID, &count); err != nil { + return nil, err + } + mc := result[agentID] + mc.AgentID = agentID + mc.Docs = count + result[agentID] = mc + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Chunk counts + rows2, err := db.QueryContext(ctx, `SELECT agent_id, COUNT(*) FROM memory_chunks GROUP BY agent_id`) + if err != nil { + return nil, err + } + defer rows2.Close() + for rows2.Next() { + var agentID uuid.UUID + var count int + if err := rows2.Scan(&agentID, &count); err != nil { + return nil, err + } + mc := result[agentID] + mc.AgentID = agentID + mc.Chunks = count + result[agentID] = mc + } + return result, rows2.Err() +} + +func queryKGCounts(ctx context.Context, db *sql.DB) (map[uuid.UUID]agentKGCounts, error) { + result := make(map[uuid.UUID]agentKGCounts) + + rows, err := db.QueryContext(ctx, `SELECT agent_id, COUNT(*) FROM kg_entities GROUP BY agent_id`) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var agentID uuid.UUID + var count int + if err := rows.Scan(&agentID, &count); err != nil { + return nil, err + } + kc := result[agentID] + kc.AgentID = agentID + kc.Entities = count + result[agentID] = kc + } + if err := rows.Err(); err != nil { + return nil, err + } + + rows2, err := db.QueryContext(ctx, `SELECT agent_id, COUNT(*) FROM kg_relations GROUP BY agent_id`) + if err != nil { + return nil, err + } + defer rows2.Close() + for rows2.Next() { + var agentID uuid.UUID + var count int + if err := rows2.Scan(&agentID, &count); err != nil { + return nil, err + } + kc := result[agentID] + kc.AgentID = agentID + kc.Relations = count + result[agentID] = kc + } + return result, rows2.Err() +} + +// agentChannelKey is a composite key for the merge map. +type agentChannelKey struct { + AgentID uuid.UUID // zero UUID for nil agent_id + Channel string +} + +func mergeTraceAndSpanRows( + bucketStart time.Time, + traceRows []traceAggregate, + spanRows []spanAggregate, + memoryCounts map[uuid.UUID]agentMemoryCounts, + kgCounts map[uuid.UUID]agentKGCounts, +) []store.UsageSnapshot { + var snapshots []store.UsageSnapshot + seenAgents := make(map[agentChannelKey]bool) + + // 1. Create "totals" rows from trace data (provider='', model='') + for _, tr := range traceRows { + key := agentChannelKey{Channel: tr.Channel} + if tr.AgentID != nil { + key.AgentID = *tr.AgentID + } + seenAgents[key] = true + + snap := store.UsageSnapshot{ + BucketHour: bucketStart, + AgentID: tr.AgentID, + Provider: "", + Model: "", + Channel: tr.Channel, + RequestCount: tr.RequestCount, + ErrorCount: tr.ErrorCount, + UniqueUsers: tr.UniqueUsers, + ToolCallCount: tr.ToolCallCount, + AvgDurationMS: tr.AvgDurationMS, + } + + // Attach memory/KG counts to totals row + if tr.AgentID != nil { + if mc, ok := memoryCounts[*tr.AgentID]; ok { + snap.MemoryDocs = mc.Docs + snap.MemoryChunks = mc.Chunks + } + if kc, ok := kgCounts[*tr.AgentID]; ok { + snap.KGEntities = kc.Entities + snap.KGRelations = kc.Relations + } + } + + snapshots = append(snapshots, snap) + } + + // 2. Create detail rows from span data (with actual provider/model) + for _, sp := range spanRows { + snapshots = append(snapshots, store.UsageSnapshot{ + BucketHour: bucketStart, + AgentID: sp.AgentID, + Provider: sp.Provider, + Model: sp.Model, + Channel: sp.Channel, + LLMCallCount: sp.LLMCallCount, + InputTokens: sp.InputTokens, + OutputTokens: sp.OutputTokens, + TotalCost: sp.TotalCost, + CacheReadTokens: sp.CacheReadTokens, + CacheCreateTokens: sp.CacheCreateTokens, + ThinkingTokens: sp.ThinkingTokens, + }) + } + + // 3. Create memory/KG-only totals rows for agents without traces this hour + if memoryCounts != nil || kgCounts != nil { + allAgents := make(map[uuid.UUID]bool) + for id := range memoryCounts { + allAgents[id] = true + } + for id := range kgCounts { + allAgents[id] = true + } + for agentID := range allAgents { + key := agentChannelKey{AgentID: agentID} + if seenAgents[key] { + continue + } + aid := agentID + snap := store.UsageSnapshot{ + BucketHour: bucketStart, + AgentID: &aid, + Provider: "", + Model: "", + Channel: "", + } + if mc, ok := memoryCounts[agentID]; ok { + snap.MemoryDocs = mc.Docs + snap.MemoryChunks = mc.Chunks + } + if kc, ok := kgCounts[agentID]; ok { + snap.KGEntities = kc.Entities + snap.KGRelations = kc.Relations + } + snapshots = append(snapshots, snap) + } + } + + return snapshots +} diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 27a650a0..87c7da87 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 15 +const RequiredSchemaVersion uint = 16 diff --git a/migrations/000016_usage_snapshots.down.sql b/migrations/000016_usage_snapshots.down.sql new file mode 100644 index 00000000..0d6cc4b2 --- /dev/null +++ b/migrations/000016_usage_snapshots.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS usage_snapshots; +DROP INDEX IF EXISTS idx_traces_start_root; +DROP INDEX IF EXISTS idx_spans_trace_type; diff --git a/migrations/000016_usage_snapshots.up.sql b/migrations/000016_usage_snapshots.up.sql new file mode 100644 index 00000000..8b13e5fd --- /dev/null +++ b/migrations/000016_usage_snapshots.up.sql @@ -0,0 +1,79 @@ +-- ============================================================ +-- Part 1: New indexes on EXISTING tables (optimize aggregation) +-- ============================================================ + +-- Traces: snapshot worker scans by start_time for root traces only +-- Replaces Seq Scan (2.5ms→0.1ms at current scale, critical at 100K+ rows) +CREATE INDEX IF NOT EXISTS idx_traces_start_root ON traces (start_time DESC) + WHERE parent_trace_id IS NULL; + +-- Spans: snapshot worker joins on trace_id filtering by span_type +-- Current idx_spans_trace is (trace_id, start_time) — start_time useless here +-- This index lets PG filter span_type IN the index, avoiding wide-row fetches +CREATE INDEX IF NOT EXISTS idx_spans_trace_type ON spans (trace_id, span_type); + +-- ============================================================ +-- Part 2: usage_snapshots table +-- ============================================================ + +CREATE TABLE IF NOT EXISTS usage_snapshots ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + bucket_hour TIMESTAMPTZ NOT NULL, + agent_id UUID, + provider VARCHAR(50) NOT NULL DEFAULT '', + model VARCHAR(200) NOT NULL DEFAULT '', + channel VARCHAR(50) NOT NULL DEFAULT '', + + -- Token metrics + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_tokens BIGINT NOT NULL DEFAULT 0, + cache_create_tokens BIGINT NOT NULL DEFAULT 0, + thinking_tokens BIGINT NOT NULL DEFAULT 0, + + -- Cost + total_cost NUMERIC(12,6) NOT NULL DEFAULT 0, + + -- Counts + request_count INTEGER NOT NULL DEFAULT 0, + llm_call_count INTEGER NOT NULL DEFAULT 0, + tool_call_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + unique_users INTEGER NOT NULL DEFAULT 0, + + -- Duration + avg_duration_ms INTEGER NOT NULL DEFAULT 0, + + -- Memory & Knowledge Graph (point-in-time counts) + memory_docs INTEGER NOT NULL DEFAULT 0, + memory_chunks INTEGER NOT NULL DEFAULT 0, + kg_entities INTEGER NOT NULL DEFAULT 0, + kg_relations INTEGER NOT NULL DEFAULT 0, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================ +-- Part 3: usage_snapshots indexes +-- ============================================================ + +-- Time-series queries: GROUP BY bucket_hour ORDER BY bucket_hour +CREATE INDEX IF NOT EXISTS idx_usage_snapshots_bucket ON usage_snapshots (bucket_hour DESC); + +-- Agent-scoped time-series: WHERE agent_id = $1 ORDER BY bucket_hour +CREATE INDEX IF NOT EXISTS idx_usage_snapshots_agent_bucket ON usage_snapshots (agent_id, bucket_hour DESC); + +-- Cross-filter: WHERE provider = $1 AND bucket_hour BETWEEN ... +CREATE INDEX IF NOT EXISTS idx_usage_snapshots_provider_bucket ON usage_snapshots (provider, bucket_hour DESC) + WHERE provider != ''; + +-- Cross-filter: WHERE channel = $1 AND bucket_hour BETWEEN ... +CREATE INDEX IF NOT EXISTS idx_usage_snapshots_channel_bucket ON usage_snapshots (channel, bucket_hour DESC) + WHERE channel != ''; + +-- Upsert dedup: ON CONFLICT — prevents duplicate snapshot rows +CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_snapshots_unique ON usage_snapshots ( + bucket_hour, + COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), + provider, model, channel +); diff --git a/ui/web/package.json b/ui/web/package.json index bdf99949..f70cac8c 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -20,6 +20,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "d3-force": "^3.0.0", + "date-fns": "^4.1.0", "framer-motion": "^12.34.3", "highlight.js": "^11.11.1", "i18next": "^25.8.15", @@ -30,6 +31,7 @@ "react-i18next": "^16.5.6", "react-markdown": "^10.1.0", "react-router": "^7.1.0", + "recharts": "^3.8.0", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^2.6.0", diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index 17212fe9..c0fbefd0 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@xyflow/react': specifier: ^12.10.1 - version: 12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 12.10.1(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -38,6 +38,9 @@ importers: d3-force: specifier: ^3.0.0 version: 3.0.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 framer-motion: specifier: ^12.34.3 version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -68,6 +71,9 @@ importers: react-router: specifier: ^7.1.0 version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + recharts: + specifier: ^3.8.0 + version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1) rehype-highlight: specifier: ^7.0.2 version: 7.0.2 @@ -79,7 +85,7 @@ importers: version: 2.6.1 zustand: specifier: ^5.0.0 - version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + version: 5.0.11(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: '@tailwindcss/vite': specifier: ^4.0.0 @@ -1100,6 +1106,17 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1241,6 +1258,12 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tailwindcss/node@4.2.0': resolution: {integrity: sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==} @@ -1371,21 +1394,42 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} '@types/d3-drag@3.0.7': resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + '@types/d3-force@3.0.10': resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} '@types/d3-interpolate@3.0.4': resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + '@types/d3-selection@3.0.11': resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@3.0.9': resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} @@ -1427,6 +1471,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -1511,6 +1558,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} @@ -1531,18 +1582,42 @@ packages: resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} engines: {node: '>=12'} + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + d3-interpolate@3.0.1: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + d3-quadtree@3.0.1: resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} engines: {node: '>=12'} + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + d3-selection@3.0.0: resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} engines: {node: '>=12'} + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + d3-timer@3.0.1: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} @@ -1557,6 +1632,9 @@ packages: resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} engines: {node: '>=12'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1566,6 +1644,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -1590,6 +1671,9 @@ packages: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1606,6 +1690,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1678,9 +1765,19 @@ packages: typescript: optional: true + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2009,12 +2106,27 @@ packages: typescript: optional: true + react-is@19.2.4: + resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: '@types/react': '>=18' react: '>=18' + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -2063,6 +2175,22 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + recharts@3.8.0: + resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + rehype-highlight@7.0.2: resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} @@ -2078,6 +2206,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2119,6 +2250,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -2201,6 +2335,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -3298,6 +3435,18 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.57.1': @@ -3375,6 +3524,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@tailwindcss/node@4.2.0': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3484,20 +3637,38 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/d3-array@3.2.2': {} + '@types/d3-color@3.1.3': {} '@types/d3-drag@3.0.7': dependencies: '@types/d3-selection': 3.0.11 + '@types/d3-ease@3.0.2': {} + '@types/d3-force@3.0.10': {} '@types/d3-interpolate@3.0.4': dependencies: '@types/d3-color': 3.1.3 + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': dependencies: '@types/d3-selection': 3.0.11 @@ -3543,6 +3714,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.3.0 @@ -3561,13 +3734,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@xyflow/react@12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@xyflow/react@12.10.1(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@xyflow/system': 0.0.75 classcat: 5.0.5 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.14)(react@19.2.4) + zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) transitivePeerDependencies: - '@types/react' - immer @@ -3630,6 +3803,10 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + d3-color@3.1.0: {} d3-dispatch@3.0.1: {} @@ -3647,14 +3824,38 @@ snapshots: d3-quadtree: 3.0.1 d3-timer: 3.0.1 + d3-format@3.1.2: {} + d3-interpolate@3.0.1: dependencies: d3-color: 3.1.0 + d3-path@3.1.0: {} + d3-quadtree@3.0.1: {} + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + d3-timer@3.0.1: {} d3-transition@3.0.1(d3-selection@3.0.0): @@ -3674,10 +3875,14 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + date-fns@4.1.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -3699,6 +3904,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + es-toolkit@1.45.1: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -3734,6 +3941,8 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + eventemitter3@5.0.4: {} + extend@3.0.2: {} fdir@6.5.0(picomatch@4.0.3): @@ -3807,8 +4016,14 @@ snapshots: optionalDependencies: typescript: 5.7.3 + immer@10.2.0: {} + + immer@11.1.4: {} + inline-style-parser@0.2.7: {} + internmap@2.0.3: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -4363,6 +4578,8 @@ snapshots: react-dom: 19.2.4(react@19.2.4) typescript: 5.7.3 + react-is@19.2.4: {} + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@types/hast': 3.0.4 @@ -4381,6 +4598,15 @@ snapshots: transitivePeerDependencies: - supports-color + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -4420,6 +4646,32 @@ snapshots: react@19.2.4: {} + recharts@3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + rehype-highlight@7.0.2: dependencies: '@types/hast': 3.0.4 @@ -4462,6 +4714,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + reselect@5.1.1: {} + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -4522,6 +4776,8 @@ snapshots: tapable@2.3.0: {} + tiny-invariant@1.3.3: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -4612,6 +4868,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.25.12 @@ -4632,16 +4905,18 @@ snapshots: yallist@3.1.1: {} - zustand@4.5.7(@types/react@19.2.14)(react@19.2.4): + zustand@4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4): dependencies: use-sync-external-store: 1.6.0(react@19.2.4) optionalDependencies: '@types/react': 19.2.14 + immer: 11.1.4 react: 19.2.4 - zustand@5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + zustand@5.0.11(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 + immer: 11.1.4 react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) diff --git a/ui/web/src/components/layout/topbar.tsx b/ui/web/src/components/layout/topbar.tsx index f4b654c7..e428ab4b 100644 --- a/ui/web/src/components/layout/topbar.tsx +++ b/ui/web/src/components/layout/topbar.tsx @@ -1,11 +1,11 @@ -import { Moon, Sun, PanelLeftClose, PanelLeftOpen, Menu, LogOut, Bell, Globe } from "lucide-react"; +import { Moon, Sun, PanelLeftClose, PanelLeftOpen, Menu, LogOut, Bell, Globe, Clock } from "lucide-react"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import { useUiStore } from "@/stores/use-ui-store"; import { useAuthStore } from "@/stores/use-auth-store"; import { useIsMobile } from "@/hooks/use-media-query"; import { usePendingPairingsCount } from "@/hooks/use-pending-pairings-count"; -import { ROUTES, SUPPORTED_LANGUAGES, LANGUAGE_LABELS, type Language } from "@/lib/constants"; +import { ROUTES, SUPPORTED_LANGUAGES, LANGUAGE_LABELS, TIMEZONE_OPTIONS, type Language } from "@/lib/constants"; export function Topbar() { const { t } = useTranslation("topbar"); @@ -13,10 +13,11 @@ export function Topbar() { const setTheme = useUiStore((s) => s.setTheme); const language = useUiStore((s) => s.language); const setLanguage = useUiStore((s) => s.setLanguage); + const timezone = useUiStore((s) => s.timezone); + const setTimezone = useUiStore((s) => s.setTimezone); const sidebarCollapsed = useUiStore((s) => s.sidebarCollapsed); const toggleSidebar = useUiStore((s) => s.toggleSidebar); const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen); - const userId = useAuthStore((s) => s.userId); const logout = useAuthStore((s) => s.logout); const isMobile = useIsMobile(); const navigate = useNavigate(); @@ -51,10 +52,6 @@ export function Topbar() {
- {userId && !isMobile && ( - {userId} - )} -
+
+ + +
+ + ))} + + +
+ {/* Agent dropdown */} + {agents.length > 0 && ( + + )} + + {/* Provider dropdown */} + {providerBreakdown.length > 0 && ( + + )} + + {/* Channel dropdown */} + {channelBreakdown.length > 0 && ( + + )} + + {onExportCsv && ( + + )} +
+ + + {/* Active filter chips */} + {chips.length > 0 && ( +
+ {t("analytics.activeFilters")}: + {chips.map((chip) => ( + + {chip.label} + + + ))} + {activeFilterCount > 0 && ( + + )} +
+ )} + + ); +} diff --git a/ui/web/src/pages/usage/components/knowledge-chart.tsx b/ui/web/src/pages/usage/components/knowledge-chart.tsx new file mode 100644 index 00000000..49d7f22d --- /dev/null +++ b/ui/web/src/pages/usage/components/knowledge-chart.tsx @@ -0,0 +1,58 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, + Tooltip, ResponsiveContainer, Legend, +} from "recharts"; +import { formatBucketTz } from "@/lib/format"; +import { useUiStore } from "@/stores/use-ui-store"; +import { ChartWrapper } from "./chart-wrapper"; +import type { SnapshotTimeSeries } from "../hooks/use-usage-analytics"; + +interface KnowledgeChartProps { + data: SnapshotTimeSeries[]; + loading?: boolean; + granularity: "hour" | "day"; +} + +export function KnowledgeChart({ data, loading, granularity }: KnowledgeChartProps) { + const { t } = useTranslation("usage"); + const timezone = useUiStore((s) => s.timezone); + + const hasData = data.some( + (d) => d.memory_docs > 0 || d.memory_chunks > 0 || d.kg_entities > 0 || d.kg_relations > 0, + ); + + if (!loading && !hasData) return null; + + const chartData = useMemo(() => data.map((d) => ({ + label: formatBucketTz(d.bucket_time, timezone, granularity), + memory_docs: d.memory_docs, + memory_chunks: d.memory_chunks, + kg_entities: d.kg_entities, + kg_relations: d.kg_relations, + })), [data, granularity, timezone]); + + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/ui/web/src/pages/usage/components/request-volume-chart.tsx b/ui/web/src/pages/usage/components/request-volume-chart.tsx new file mode 100644 index 00000000..2d0b1d73 --- /dev/null +++ b/ui/web/src/pages/usage/components/request-volume-chart.tsx @@ -0,0 +1,74 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { + ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, + Tooltip, ResponsiveContainer, Legend, +} from "recharts"; +import { formatBucketTz } from "@/lib/format"; +import { useUiStore } from "@/stores/use-ui-store"; +import { ChartWrapper } from "./chart-wrapper"; +import type { SnapshotTimeSeries } from "../hooks/use-usage-analytics"; + +interface RequestVolumeChartProps { + data: SnapshotTimeSeries[]; + loading?: boolean; + granularity: "hour" | "day"; +} + +export function RequestVolumeChart({ data, loading, granularity }: RequestVolumeChartProps) { + const { t } = useTranslation("usage"); + const timezone = useUiStore((s) => s.timezone); + const isEmpty = !loading && data.length === 0; + + const chartData = useMemo(() => data.map((d) => ({ + label: formatBucketTz(d.bucket_time, timezone, granularity), + request_count: d.request_count, + error_count: d.error_count, + errorRate: d.request_count > 0 ? +((d.error_count / d.request_count) * 100).toFixed(1) : 0, + })), [data, granularity, timezone]); + + return ( + + + + + + + `${v}`} /> + { + const v = typeof value === "number" ? value : Number(value); + const n = String(name); + if (n === t("analytics.requestChart.requests")) return [v, n]; + const rate = (props.payload as { errorRate?: number })?.errorRate ?? 0; + return [v, `${n} (${rate}% rate)`]; + }} + labelFormatter={(label) => `${t("analytics.tooltip.date")}: ${label}`} + /> + + + + + + + ); +} diff --git a/ui/web/src/pages/usage/components/summary-cards.tsx b/ui/web/src/pages/usage/components/summary-cards.tsx new file mode 100644 index 00000000..7394d507 --- /dev/null +++ b/ui/web/src/pages/usage/components/summary-cards.tsx @@ -0,0 +1,108 @@ +import { useTranslation } from "react-i18next"; +import { TrendingUp, TrendingDown, Minus } from "lucide-react"; +import { formatTokens, formatCost } from "@/lib/format"; +import type { SummaryData } from "../hooks/use-usage-analytics"; + +interface SummaryCardsProps { + current: SummaryData; + previous: SummaryData; + loading?: boolean; +} + +function trendPercent(curr: number, prev: number): number | null { + if (prev === 0) return null; + return ((curr - prev) / prev) * 100; +} + +interface StatCardProps { + label: string; + value: string; + trend: number | null; + hint?: string; +} + +function StatCard({ label, value, trend, hint }: StatCardProps) { + const { t } = useTranslation("usage"); + const isUp = trend !== null && trend > 0; + const isDown = trend !== null && trend < 0; + + return ( +
+

{label}

+

{value}

+ {trend !== null ? ( +
+ {isUp ? : isDown ? : } + + {isUp + ? t("analytics.trendUp", { value: Math.abs(trend).toFixed(1) }) + : isDown + ? t("analytics.trendDown", { value: trend.toFixed(1) }) + : "0%"} + + {t("analytics.vsPrevious")} +
+ ) : ( +

{t("analytics.vsPrevious")}: N/A

+ )} +
+ ); +} + +export function SummaryCards({ current, previous, loading }: SummaryCardsProps) { + const { t } = useTranslation("usage"); + + if (loading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + const allCostZero = current.cost === 0 && previous.cost === 0; + const currentTokens = current.input_tokens + current.output_tokens; + const previousTokens = previous.input_tokens + previous.output_tokens; + + const cards: StatCardProps[] = [ + { + label: t("analytics.requests"), + value: current.requests.toLocaleString(), + trend: trendPercent(current.requests, previous.requests), + }, + { + label: t("analytics.tokens"), + value: formatTokens(currentTokens), + trend: trendPercent(currentTokens, previousTokens), + }, + { + label: t("analytics.cost"), + value: formatCost(current.cost), + trend: trendPercent(current.cost, previous.cost), + hint: allCostZero ? t("analytics.configurePricing") : undefined, + }, + { + label: t("analytics.errors"), + value: current.errors.toLocaleString(), + trend: trendPercent(current.errors, previous.errors), + }, + { + label: t("analytics.uniqueUsers"), + value: current.unique_users.toLocaleString(), + trend: trendPercent(current.unique_users, previous.unique_users), + }, + ]; + + return ( +
+ {cards.map((card) => ( + + ))} +
+ ); +} diff --git a/ui/web/src/pages/usage/components/token-area-chart.tsx b/ui/web/src/pages/usage/components/token-area-chart.tsx new file mode 100644 index 00000000..446fdc8d --- /dev/null +++ b/ui/web/src/pages/usage/components/token-area-chart.tsx @@ -0,0 +1,94 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { + AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, Brush, Legend, +} from "recharts"; +import { formatTokens, formatBucketTz } from "@/lib/format"; +import { useUiStore } from "@/stores/use-ui-store"; +import { ChartWrapper } from "./chart-wrapper"; +import type { SnapshotTimeSeries } from "../hooks/use-usage-analytics"; + +interface TokenAreaChartProps { + data: SnapshotTimeSeries[]; + loading?: boolean; + granularity: "hour" | "day"; +} + +export function TokenAreaChart({ data, loading, granularity }: TokenAreaChartProps) { + const { t } = useTranslation("usage"); + const timezone = useUiStore((s) => s.timezone); + + const isEmpty = !loading && data.length === 0; + + const { chartData, hasCache } = useMemo(() => { + let cache = false; + const mapped = data.map((d) => { + if (d.cache_read_tokens > 0) cache = true; + return { ...d, label: formatBucketTz(d.bucket_time, timezone, granularity) }; + }); + return { chartData: mapped, hasCache: cache }; + }, [data, granularity, timezone]); + + return ( + + + + + + + + + + + + + + + + formatTokens(v)} tick={{ fontSize: 11 }} width={52} /> + [formatTokens(typeof value === "number" ? value : Number(value)), String(name)]} + labelFormatter={(label) => `${t("analytics.tooltip.date")}: ${label}`} + /> + + + + {hasCache && ( + + )} + + + + + ); +} diff --git a/ui/web/src/pages/usage/components/top-models-table.tsx b/ui/web/src/pages/usage/components/top-models-table.tsx new file mode 100644 index 00000000..89dce8d5 --- /dev/null +++ b/ui/web/src/pages/usage/components/top-models-table.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"; +import { formatTokens, formatCost, formatDuration } from "@/lib/format"; +import { cn } from "@/lib/utils"; +import { useUsageFilterContext } from "../context/usage-filter-context"; +import type { SnapshotBreakdown } from "../hooks/use-usage-analytics"; + +type SortKey = "llm_call_count" | "input_tokens" | "output_tokens" | "avg_duration_ms" | "total_cost"; + +interface TopModelsTableProps { + data: SnapshotBreakdown[]; + loading?: boolean; +} + +export function TopModelsTable({ data, loading }: TopModelsTableProps) { + const { t } = useTranslation("usage"); + const { filters, toggleFilter } = useUsageFilterContext(); + const [sortKey, setSortKey] = useState("llm_call_count"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDir((d) => (d === "desc" ? "asc" : "desc")); + } else { + setSortKey(key); + setSortDir("desc"); + } + }; + + const sorted = [...data].sort((a, b) => { + const av = a[sortKey] ?? 0; + const bv = b[sortKey] ?? 0; + return sortDir === "desc" ? bv - av : av - bv; + }); + + function SortIcon({ col }: { col: SortKey }) { + if (sortKey !== col) return ; + return sortDir === "desc" + ? + : ; + } + + function ThSort({ col, label }: { col: SortKey; label: string }) { + return ( + handleSort(col)} + > + + {label} + + + + ); + } + + if (loading) { + return ( +
+

{t("analytics.topModels.title")}

+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (data.length === 0) return null; + + return ( +
+
+

{t("analytics.topModels.title")}

+
+
+ + + + + + + + + + + + + + {sorted.map((row) => { + const isActive = filters.model === row.key; + const [model, provider] = row.key.includes("/") + ? row.key.split("/", 2) + : [row.key, "—"]; + return ( + toggleFilter("model", row.key)} + > + + + + + + + + + ); + })} + +
{t("analytics.topModels.model")}{t("analytics.topModels.provider")}
{model}{provider}{row.llm_call_count.toLocaleString()}{formatTokens(row.input_tokens)}{formatTokens(row.output_tokens)}{formatDuration(row.avg_duration_ms)}{formatCost(row.total_cost)}
+
+
+ ); +} diff --git a/ui/web/src/pages/usage/context/usage-filter-context.tsx b/ui/web/src/pages/usage/context/usage-filter-context.tsx new file mode 100644 index 00000000..76bc9d0b --- /dev/null +++ b/ui/web/src/pages/usage/context/usage-filter-context.tsx @@ -0,0 +1,110 @@ +import { createContext, useContext, useState, useCallback, ReactNode } from "react"; +import { subDays, subHours } from "date-fns"; + +export type Period = "24h" | "7d" | "30d" | "custom"; + +export interface UsageFilters { + from: string; + to: string; + period: Period; + agentId?: string; + provider?: string; + model?: string; + channel?: string; + granularity: "hour" | "day"; +} + +interface UsageFilterContextValue { + filters: UsageFilters; + setFilter: (key: keyof UsageFilters, value: string | undefined) => void; + toggleFilter: (key: "provider" | "model" | "channel" | "agentId", value: string) => void; + setPeriod: (period: Period) => void; + clearFilters: () => void; + activeFilterCount: number; +} + +function buildTimeRange(period: Period): { from: string; to: string; granularity: "hour" | "day" } { + const now = new Date(); + let from: Date; + let granularity: "hour" | "day"; + if (period === "24h") { + from = subHours(now, 24); + granularity = "hour"; + } else if (period === "7d") { + from = subDays(now, 7); + granularity = "hour"; + } else { + from = subDays(now, 30); + granularity = "day"; + } + return { from: from.toISOString(), to: now.toISOString(), granularity }; +} + +function defaultFilters(): UsageFilters { + const { from, to, granularity } = buildTimeRange("7d"); + return { from, to, period: "7d", granularity }; +} + +const UsageFilterContext = createContext(null); + +export function UsageFilterProvider({ children }: { children: ReactNode }) { + const [filters, setFilters] = useState(defaultFilters); + + const setFilter = useCallback((key: keyof UsageFilters, value: string | undefined) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }, []); + + const toggleFilter = useCallback( + (key: "provider" | "model" | "channel" | "agentId", value: string) => { + setFilters((prev) => ({ + ...prev, + [key]: prev[key] === value ? undefined : value, + })); + }, + [], + ); + + const setPeriod = useCallback((period: Period) => { + if (period === "custom") { + setFilters((prev) => ({ ...prev, period })); + return; + } + const { from, to, granularity } = buildTimeRange(period); + setFilters((prev) => ({ + ...prev, + from, + to, + period, + granularity, + })); + }, []); + + const clearFilters = useCallback(() => { + setFilters((prev) => ({ + ...prev, + agentId: undefined, + provider: undefined, + model: undefined, + channel: undefined, + })); + }, []); + + const activeFilterCount = [ + filters.agentId, + filters.provider, + filters.model, + filters.channel, + ].filter(Boolean).length; + + return ( + + {children} + + ); +} + +export function useUsageFilterContext(): UsageFilterContextValue { + const ctx = useContext(UsageFilterContext); + if (!ctx) throw new Error("useUsageFilterContext must be used within UsageFilterProvider"); + return ctx; +} diff --git a/ui/web/src/pages/usage/hooks/use-usage-analytics.ts b/ui/web/src/pages/usage/hooks/use-usage-analytics.ts new file mode 100644 index 00000000..37e44e6b --- /dev/null +++ b/ui/web/src/pages/usage/hooks/use-usage-analytics.ts @@ -0,0 +1,135 @@ +import { useQuery } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import type { UsageFilters } from "../context/usage-filter-context"; + +export interface SnapshotTimeSeries { + bucket_time: string; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + thinking_tokens: number; + request_count: number; + llm_call_count: number; + tool_call_count: number; + error_count: number; + avg_duration_ms: number; + unique_users: number; + memory_docs: number; + memory_chunks: number; + kg_entities: number; + kg_relations: number; + total_cost: number; +} + +export interface SnapshotBreakdown { + key: string; + request_count: number; + llm_call_count: number; + input_tokens: number; + output_tokens: number; + error_count: number; + avg_duration_ms: number; + total_cost: number; +} + +export interface SummaryData { + requests: number; + input_tokens: number; + output_tokens: number; + cost: number; + errors: number; + unique_users: number; + llm_calls: number; + tool_calls: number; + avg_duration_ms: number; +} + +interface SummaryResponse { + current: SummaryData; + previous: SummaryData; +} + +function buildParams(filters: UsageFilters, extra?: Record): Record { + const p: Record = { + from: filters.from, + to: filters.to, + }; + if (filters.agentId) p.agent_id = filters.agentId; + if (filters.provider) p.provider = filters.provider; + if (filters.model) p.model = filters.model; + if (filters.channel) p.channel = filters.channel; + return { ...p, ...extra }; +} + +// Stable query key: only values that affect the query, not the full filters object reference. +function filterKey(f: UsageFilters) { + return [f.from, f.to, f.agentId, f.provider, f.model, f.channel] as const; +} + +// Snapshots update hourly — no need to re-fetch on window focus or within 60s. +const STALE_TIME = 60_000; +const QUERY_OPTS = { staleTime: STALE_TIME, refetchOnWindowFocus: false } as const; + +export function useUsageAnalytics(filters: UsageFilters) { + const http = useHttp(); + const fk = filterKey(filters); + + const timeseriesQuery = useQuery({ + queryKey: ["usage", "timeseries", filters.granularity, ...fk], + queryFn: () => + http.get<{ points: SnapshotTimeSeries[] }>("/v1/usage/timeseries", buildParams(filters, { group_by: filters.granularity })), + placeholderData: (prev) => prev, + ...QUERY_OPTS, + }); + + const providerQuery = useQuery({ + queryKey: ["usage", "breakdown", "provider", ...fk], + queryFn: () => + http.get<{ rows: SnapshotBreakdown[] }>("/v1/usage/breakdown", buildParams(filters, { group_by: "provider" })), + placeholderData: (prev) => prev, + ...QUERY_OPTS, + }); + + const modelQuery = useQuery({ + queryKey: ["usage", "breakdown", "model", ...fk], + queryFn: () => + http.get<{ rows: SnapshotBreakdown[] }>("/v1/usage/breakdown", buildParams(filters, { group_by: "model" })), + placeholderData: (prev) => prev, + ...QUERY_OPTS, + }); + + const channelQuery = useQuery({ + queryKey: ["usage", "breakdown", "channel", ...fk], + queryFn: () => + http.get<{ rows: SnapshotBreakdown[] }>("/v1/usage/breakdown", buildParams(filters, { group_by: "channel" })), + placeholderData: (prev) => prev, + ...QUERY_OPTS, + }); + + const summaryQuery = useQuery({ + queryKey: ["usage", "summary", filters.period, ...fk], + queryFn: () => + http.get("/v1/usage/summary", buildParams(filters, { period: filters.period })), + placeholderData: (prev) => prev, + ...QUERY_OPTS, + }); + + // isLoading = first mount only (no cached data) → shows skeleton. + // placeholderData keeps previous results visible during refetch → no flicker. + const loading = + timeseriesQuery.isLoading || + providerQuery.isLoading || + modelQuery.isLoading || + channelQuery.isLoading || + summaryQuery.isLoading; + + return { + timeseries: timeseriesQuery.data?.points ?? [], + providerBreakdown: providerQuery.data?.rows ?? [], + modelBreakdown: modelQuery.data?.rows ?? [], + channelBreakdown: channelQuery.data?.rows ?? [], + summary: summaryQuery.data ?? null, + loading, + error: timeseriesQuery.error, + }; +} diff --git a/ui/web/src/pages/usage/usage-page.tsx b/ui/web/src/pages/usage/usage-page.tsx index fe09962b..6105da5a 100644 --- a/ui/web/src/pages/usage/usage-page.tsx +++ b/ui/web/src/pages/usage/usage-page.tsx @@ -1,144 +1,179 @@ -import { useState, useEffect } from "react"; -import { BarChart3, RefreshCw } from "lucide-react"; +import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; +import { BarChart3, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { PageHeader } from "@/components/shared/page-header"; import { EmptyState } from "@/components/shared/empty-state"; import { Pagination } from "@/components/shared/pagination"; -import { TableSkeleton } from "@/components/shared/loading-skeleton"; -import { formatTokens } from "@/lib/format"; +import { formatTokens, formatCost } from "@/lib/format"; +import { useAgents } from "@/pages/agents/hooks/use-agents"; import { useUsage } from "./hooks/use-usage"; -import { useMinLoading } from "@/hooks/use-min-loading"; -import { useDeferredLoading } from "@/hooks/use-deferred-loading"; +import { useUsageAnalytics } from "./hooks/use-usage-analytics"; +import { UsageFilterProvider, useUsageFilterContext } from "./context/usage-filter-context"; +import { FilterBar } from "./components/filter-bar"; +import { SummaryCards } from "./components/summary-cards"; +import { TokenAreaChart } from "./components/token-area-chart"; +import { RequestVolumeChart } from "./components/request-volume-chart"; +import { DistributionRow } from "./components/distribution-row"; +import { DurationChart } from "./components/duration-chart"; +import { KnowledgeChart } from "./components/knowledge-chart"; +import { TopModelsTable } from "./components/top-models-table"; -export function UsagePage() { +const EMPTY_SUMMARY = { requests: 0, input_tokens: 0, output_tokens: 0, cost: 0, errors: 0, unique_users: 0, llm_calls: 0, tool_calls: 0, avg_duration_ms: 0 }; + +function AnalyticsDashboard() { const { t } = useTranslation("usage"); - const { records, total, summary, loading, loadRecords, loadSummary } = useUsage(); - const spinning = useMinLoading(loading); - const showSkeleton = useDeferredLoading(loading && records.length === 0); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); + const { filters, setFilter } = useUsageFilterContext(); + const { agents } = useAgents(); + const { timeseries, providerBreakdown, modelBreakdown, channelBreakdown, summary, loading, error } = + useUsageAnalytics(filters); + // Legacy records table state + const { records, total, loading: recLoading, loadRecords } = useUsage(); + const [page, setPage] = useState(1); + const [pageSize] = useState(20); const totalPages = Math.max(1, Math.ceil(total / pageSize)); useEffect(() => { - loadRecords({ limit: pageSize, offset: (page - 1) * pageSize }); - }, [page, pageSize]); + loadRecords({ limit: pageSize, offset: (page - 1) * pageSize, agentId: filters.agentId }); + }, [page, pageSize, filters.agentId]); - const handleRefresh = () => { - loadRecords({ limit: pageSize, offset: (page - 1) * pageSize }); - loadSummary(); - }; + const agentList = agents.map((a) => ({ + id: a.id, + name: (a as { display_name?: string }).display_name || a.agent_key || a.id, + })); - const agentEntries = summary?.byAgent - ? Object.entries(summary.byAgent).sort( - ([, a], [, b]) => b.totalTokens - a.totalTokens, - ) - : []; + const handleExportCsv = useCallback(() => { + const rows = [ + ["Date", "Input Tokens", "Output Tokens", "Requests", "LLM Calls", "Tool Calls", "Errors", "Cost"], + ...timeseries.map((d) => [ + d.bucket_time, + d.input_tokens, + d.output_tokens, + d.request_count, + d.llm_call_count, + d.tool_call_count, + d.error_count, + d.total_cost.toFixed(6), + ]), + ]; + const csv = rows.map((r) => r.join(",")).join("\n"); + const blob = new Blob([csv], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `usage-${filters.period}-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + }, [timeseries, filters.period]); + + const current = summary?.current ?? EMPTY_SUMMARY; + const previous = summary?.previous ?? EMPTY_SUMMARY; + + const apiError = error instanceof Error ? error.message : error ? String(error) : null; return ( -
+
- {t("common:refresh", "Refresh")} + } /> - {showSkeleton ? ( -
- -
- ) : ( - <> - {/* Summary cards */} - {summary && agentEntries.length > 0 && ( -
- {agentEntries.map(([agentId, data]) => ( -
-
-

{agentId}

- {t("summary.sessions", { count: data.sessions })} -
-
-
- {t("summary.inputTokens")} - {formatTokens(data.inputTokens)} -
-
- {t("summary.outputTokens")} - {formatTokens(data.outputTokens)} -
-
- {t("summary.total")} - {formatTokens(data.totalTokens)} -
-
-
- ))} -
- )} + 0 ? handleExportCsv : undefined} + /> - {/* Recent records table */} -
-

{t("recentRecords")}

- {records.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - {records.map((r, i) => ( - - - - - - - - - ))} - -
{t("columns.agent")}{t("columns.model")}{t("columns.provider")}{t("columns.input")}{t("columns.output")}{t("columns.total")}
{r.agentId} - {r.model} - {r.provider} - {formatTokens(r.inputTokens)} - - {formatTokens(r.outputTokens)} - - {formatTokens(r.totalTokens)} -
- { setPageSize(size); setPage(1); }} - /> -
- )} -
- + {apiError && ( +
+ {t("common:error", "Error")}: {apiError} +
)} + + + + + + + + + + + + + + {/* Legacy records table */} +
+

{t("recentRecords")}

+ {records.length === 0 && !recLoading ? ( + + ) : ( +
+
+ + + + + + + + + + + + + + {records.map((r, i) => ( + setFilter("agentId", r.agentId)} + > + + + + + + + + + ))} + +
{t("columns.agent")}{t("columns.model")}{t("columns.provider")}{t("columns.channel")}{t("columns.input")}{t("columns.output")}{t("columns.cost")}
{r.agentId}{r.model}{r.provider}{formatTokens(r.inputTokens)}{formatTokens(r.outputTokens)}{formatCost(0)}
+
+ {}} + /> +
+ )} +
); } + +export function UsagePage() { + return ( + + + + ); +} diff --git a/ui/web/src/stores/use-ui-store.ts b/ui/web/src/stores/use-ui-store.ts index 5f88003f..9de916e6 100644 --- a/ui/web/src/stores/use-ui-store.ts +++ b/ui/web/src/stores/use-ui-store.ts @@ -7,11 +7,13 @@ export type Theme = "light" | "dark" | "system"; interface UiState { theme: Theme; language: Language; + timezone: string; // IANA timezone or "auto" sidebarCollapsed: boolean; mobileSidebarOpen: boolean; setTheme: (theme: Theme) => void; setLanguage: (language: Language) => void; + setTimezone: (tz: string) => void; toggleSidebar: () => void; setSidebarCollapsed: (collapsed: boolean) => void; setMobileSidebarOpen: (open: boolean) => void; @@ -20,6 +22,7 @@ interface UiState { export const useUiStore = create((set) => ({ theme: (localStorage.getItem(LOCAL_STORAGE_KEYS.THEME) as Theme) ?? "dark", language: (i18n.language as Language) ?? "en", + timezone: localStorage.getItem(LOCAL_STORAGE_KEYS.TIMEZONE) ?? "auto", sidebarCollapsed: localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_COLLAPSED) === "true", mobileSidebarOpen: false, @@ -34,6 +37,11 @@ export const useUiStore = create((set) => ({ set({ language }); }, + setTimezone: (tz) => { + localStorage.setItem(LOCAL_STORAGE_KEYS.TIMEZONE, tz); + set({ timezone: tz }); + }, + toggleSidebar: () => set((state) => { const next = !state.sidebarCollapsed;