fix(ui): align usage data contracts, add timezone setting, and fix empty usage page (#146)

- Fix 6 data contract mismatches between Go backend JSON tags and React
  frontend TypeScript interfaces (field renames, response envelope changes)
- Add timezone selector to topbar with 12 common timezone options
- Replace date-fns formatting with native Intl.DateTimeFormat for
  timezone-aware chart labels (reduces bundle ~20KB)
- Add missing SnapshotTimeSeries fields (memory_docs, memory_chunks,
  kg_entities, kg_relations) that caused empty usage page
- Add error banner to usage page for API error visibility
- Sanitize backend error messages in usage HTTP handlers
- Add batch chunking (max 3000 rows) for snapshot upserts
- Remove userId display from topbar
- Add usage analytics i18n strings for en/vi/zh
This commit is contained in:
Viet Tran
2026-03-11 14:22:03 +07:00
committed by GitHub
parent 0926d053b0
commit 73389d2715
39 changed files with 3256 additions and 134 deletions
+22
View File
@@ -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))
+8
View File
@@ -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 }
+248
View File
@@ -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
}
+1
View File
@@ -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
}
+296
View File
@@ -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
}
+97
View File
@@ -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)
}
+1
View File
@@ -24,4 +24,5 @@ type Stores struct {
KnowledgeGraph KnowledgeGraphStore
Contacts ContactStore
Activity ActivityStore
Snapshots SnapshotStore
}
+487
View File
@@ -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
}
+1 -1
View File
@@ -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
@@ -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;
+79
View File
@@ -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
);
+2
View File
@@ -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",
+281 -6
View File
@@ -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)
+17 -7
View File
@@ -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() {
</div>
<div className="flex items-center gap-2">
{userId && !isMobile && (
<span className="text-xs text-muted-foreground">{userId}</span>
)}
<button
onClick={() => navigate(ROUTES.NODES)}
className="relative cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
@@ -79,6 +76,19 @@ export function Topbar() {
</select>
</div>
<div className="flex items-center gap-1 rounded-md px-2 py-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground" title={t("timezone")}>
<Clock className="h-4 w-4 shrink-0" />
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="cursor-pointer bg-transparent text-xs outline-none"
>
{TIMEZONE_OPTIONS.map((tz) => (
<option key={tz.value} value={tz.value}>{tz.label}</option>
))}
</select>
</div>
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="cursor-pointer rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+1
View File
@@ -5,6 +5,7 @@
"toggleTheme": "Toggle theme",
"logout": "Logout",
"language": "Language",
"timezone": "Timezone",
"pairingRequests": "Pairing requests",
"pendingPairingRequest": "{{count}} pending pairing request",
"pendingPairingRequests": "{{count}} pending pairing requests",
+77 -1
View File
@@ -16,6 +16,82 @@
"provider": "Provider",
"input": "Input",
"output": "Output",
"total": "Total"
"total": "Total",
"channel": "Channel",
"cost": "Cost",
"status": "Status"
},
"analytics": {
"title": "Usage Analytics",
"period24h": "24h",
"period7d": "7 days",
"period30d": "30 days",
"periodCustom": "Custom",
"allAgents": "All Agents",
"allProviders": "All Providers",
"allChannels": "All Channels",
"activeFilters": "Active filters",
"clearAll": "Clear all",
"exportCsv": "Export CSV",
"requests": "Requests",
"tokens": "Tokens",
"cost": "Cost",
"errors": "Errors",
"uniqueUsers": "Unique Users",
"llmCalls": "LLM Calls",
"toolCalls": "Tool Calls",
"trendUp": "+{{value}}%",
"trendDown": "{{value}}%",
"vsPrevious": "vs previous period",
"configurePricing": "Configure model pricing for cost tracking",
"noData": "No data for selected period",
"tokenChart": {
"title": "Token Usage Over Time",
"input": "Input Tokens",
"output": "Output Tokens",
"cache": "Cache Read Tokens",
"thinking": "Thinking Tokens",
"total": "Total"
},
"requestChart": {
"title": "Request Volume & Errors",
"requests": "Requests",
"errors": "Errors",
"errorRate": "Error Rate"
},
"distribution": {
"provider": "Provider Distribution",
"model": "Model Distribution",
"channel": "Channel Distribution",
"other": "Other",
"calls": "calls"
},
"durationChart": {
"title": "Duration & Performance",
"avgDuration": "Avg Duration",
"errorRate": "Error Rate %"
},
"knowledgeChart": {
"title": "Memory & Knowledge Graph Growth",
"memoryDocs": "Memory Docs",
"memoryChunks": "Memory Chunks",
"kgEntities": "KG Entities",
"kgRelations": "KG Relations"
},
"topModels": {
"title": "Top Models",
"model": "Model",
"provider": "Provider",
"llmCalls": "LLM Calls",
"inputTokens": "Input Tokens",
"outputTokens": "Output Tokens",
"avgDuration": "Avg Duration",
"cost": "Cost"
},
"tooltip": {
"date": "Date",
"total": "Total",
"errorRate": "{{value}}% error rate"
}
}
}
+1
View File
@@ -2,6 +2,7 @@
"collapseSidebar": "Thu gọn thanh bên",
"expandSidebar": "Mở rộng thanh bên",
"language": "Ngôn ngữ",
"timezone": "Múi giờ",
"languages": {
"en": "English",
"vi": "Tiếng Việt",
+77 -1
View File
@@ -16,6 +16,82 @@
"provider": "Provider",
"input": "Đầu vào",
"output": "Đầu ra",
"total": "Tổng"
"total": "Tổng",
"channel": "Kênh",
"cost": "Chi phí",
"status": "Trạng thái"
},
"analytics": {
"title": "Phân tích sử dụng",
"period24h": "24h",
"period7d": "7 ngày",
"period30d": "30 ngày",
"periodCustom": "Tùy chọn",
"allAgents": "Tất cả Agent",
"allProviders": "Tất cả Provider",
"allChannels": "Tất cả kênh",
"activeFilters": "Bộ lọc đang áp dụng",
"clearAll": "Xóa tất cả",
"exportCsv": "Xuất CSV",
"requests": "Yêu cầu",
"tokens": "Token",
"cost": "Chi phí",
"errors": "Lỗi",
"uniqueUsers": "Người dùng",
"llmCalls": "Lượt gọi LLM",
"toolCalls": "Lượt gọi công cụ",
"trendUp": "+{{value}}%",
"trendDown": "{{value}}%",
"vsPrevious": "so với kỳ trước",
"configurePricing": "Cấu hình giá model để theo dõi chi phí",
"noData": "Không có dữ liệu cho khoảng thời gian đã chọn",
"tokenChart": {
"title": "Token sử dụng theo thời gian",
"input": "Token đầu vào",
"output": "Token đầu ra",
"cache": "Token cache đọc",
"thinking": "Token suy nghĩ",
"total": "Tổng"
},
"requestChart": {
"title": "Lượng yêu cầu & Lỗi",
"requests": "Yêu cầu",
"errors": "Lỗi",
"errorRate": "Tỷ lệ lỗi"
},
"distribution": {
"provider": "Phân bố Provider",
"model": "Phân bố Model",
"channel": "Phân bố kênh",
"other": "Khác",
"calls": "lượt gọi"
},
"durationChart": {
"title": "Thời lượng & Hiệu suất",
"avgDuration": "TB thời lượng",
"errorRate": "Tỷ lệ lỗi %"
},
"knowledgeChart": {
"title": "Tăng trưởng Bộ nhớ & Đồ thị tri thức",
"memoryDocs": "Tài liệu",
"memoryChunks": "Đoạn nhớ",
"kgEntities": "Thực thể KG",
"kgRelations": "Quan hệ KG"
},
"topModels": {
"title": "Model hàng đầu",
"model": "Model",
"provider": "Provider",
"llmCalls": "Lượt gọi LLM",
"inputTokens": "Token đầu vào",
"outputTokens": "Token đầu ra",
"avgDuration": "TB thời lượng",
"cost": "Chi phí"
},
"tooltip": {
"date": "Ngày",
"total": "Tổng",
"errorRate": "Tỷ lệ lỗi {{value}}%"
}
}
}
+1
View File
@@ -2,6 +2,7 @@
"collapseSidebar": "收起侧边栏",
"expandSidebar": "展开侧边栏",
"language": "语言",
"timezone": "时区",
"languages": {
"en": "English",
"vi": "Tiếng Việt",
+77 -1
View File
@@ -16,6 +16,82 @@
"provider": "Provider",
"input": "输入",
"output": "输出",
"total": "合计"
"total": "合计",
"channel": "渠道",
"cost": "费用",
"status": "状态"
},
"analytics": {
"title": "用量分析",
"period24h": "24小时",
"period7d": "7天",
"period30d": "30天",
"periodCustom": "自定义",
"allAgents": "全部Agent",
"allProviders": "全部Provider",
"allChannels": "全部渠道",
"activeFilters": "已启用筛选",
"clearAll": "清除全部",
"exportCsv": "导出CSV",
"requests": "请求数",
"tokens": "令牌",
"cost": "费用",
"errors": "错误",
"uniqueUsers": "独立用户",
"llmCalls": "LLM调用",
"toolCalls": "工具调用",
"trendUp": "+{{value}}%",
"trendDown": "{{value}}%",
"vsPrevious": "相较前期",
"configurePricing": "配置模型定价以启用费用追踪",
"noData": "所选时间段无数据",
"tokenChart": {
"title": "令牌使用趋势",
"input": "输入令牌",
"output": "输出令牌",
"cache": "缓存读取令牌",
"thinking": "思考令牌",
"total": "合计"
},
"requestChart": {
"title": "请求量与错误",
"requests": "请求数",
"errors": "错误数",
"errorRate": "错误率"
},
"distribution": {
"provider": "Provider 分布",
"model": "模型分布",
"channel": "渠道分布",
"other": "其他",
"calls": "次调用"
},
"durationChart": {
"title": "响应时长与性能",
"avgDuration": "平均时长",
"errorRate": "错误率 %"
},
"knowledgeChart": {
"title": "记忆与知识图谱增长",
"memoryDocs": "记忆文档",
"memoryChunks": "记忆片段",
"kgEntities": "KG实体",
"kgRelations": "KG关系"
},
"topModels": {
"title": "热门模型",
"model": "模型",
"provider": "Provider",
"llmCalls": "LLM调用",
"inputTokens": "输入令牌",
"outputTokens": "输出令牌",
"avgDuration": "平均时长",
"cost": "费用"
},
"tooltip": {
"date": "日期",
"total": "合计",
"errorRate": "错误率 {{value}}%"
}
}
}
+17
View File
@@ -45,6 +45,7 @@ export const LOCAL_STORAGE_KEYS = {
THEME: "goclaw:theme",
SIDEBAR_COLLAPSED: "goclaw:sidebarCollapsed",
LANGUAGE: "goclaw:language",
TIMEZONE: "goclaw:timezone",
} as const;
export const SUPPORTED_LANGUAGES = ["en", "vi", "zh"] as const;
@@ -55,3 +56,19 @@ export const LANGUAGE_LABELS: Record<Language, string> = {
vi: "Tiếng Việt",
zh: "中文",
};
/** "auto" = browser's local timezone. */
export const TIMEZONE_OPTIONS = [
{ value: "auto", label: "Auto (Local)" },
{ value: "UTC", label: "UTC" },
{ value: "America/New_York", label: "New York (ET)" },
{ value: "America/Chicago", label: "Chicago (CT)" },
{ value: "America/Los_Angeles", label: "Los Angeles (PT)" },
{ value: "Europe/London", label: "London (GMT/BST)" },
{ value: "Europe/Paris", label: "Paris (CET)" },
{ value: "Asia/Tokyo", label: "Tokyo (JST)" },
{ value: "Asia/Shanghai", label: "Shanghai (CST)" },
{ value: "Asia/Ho_Chi_Minh", label: "Ho Chi Minh (ICT)" },
{ value: "Asia/Singapore", label: "Singapore (SGT)" },
{ value: "Australia/Sydney", label: "Sydney (AEST)" },
] as const;
+33
View File
@@ -47,6 +47,39 @@ export function formatDuration(ms: number | undefined | null): string {
return `${min}m ${remainSec}s`;
}
/**
* Resolve the effective IANA timezone string.
* "auto" browser's local timezone.
*/
export function resolveTimezone(tz: string): string {
if (tz === "auto") return Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz;
}
/**
* Format a UTC timestamp for chart labels, respecting the user's chosen timezone.
* Uses Intl.DateTimeFormat for native timezone support (no extra deps).
*/
export function formatBucketTz(
bucket: string,
tz: string,
granularity: "hour" | "day",
): string {
try {
const d = new Date(bucket);
const resolved = resolveTimezone(tz);
const opts: Intl.DateTimeFormatOptions = {
timeZone: resolved,
month: "short",
day: "numeric",
...(granularity === "hour" ? { hour: "2-digit", minute: "2-digit", hour12: false } : {}),
};
return new Intl.DateTimeFormat("en-US", opts).format(d);
} catch {
return bucket;
}
}
/**
* Compute duration in ms from start/end time strings.
* Falls back to 0 if either is missing.
@@ -0,0 +1,88 @@
import { useState, useEffect, useRef } from "react";
import { useHttp } from "@/hooks/use-ws";
interface TimeSeriesPoint {
bucket_time: string;
input_tokens: number;
output_tokens: number;
total_cost: number;
request_count: number;
}
interface SummaryData {
requests: number;
input_tokens: number;
output_tokens: number;
cost: number;
}
interface SummaryResponse {
current: SummaryData;
previous: SummaryData;
}
export interface OverviewSparklines {
requestSparkline: number[];
tokenSparkline: number[];
costSparkline: number[];
trends: {
requests: number | null;
tokens: number | null;
cost: number | null;
};
}
function computeTrend(current: number, previous: number): number | null {
if (previous === 0) return current > 0 ? 100 : null;
return Math.round(((current - previous) / previous) * 100);
}
export function useOverviewSparklines(): OverviewSparklines | null {
const http = useHttp();
const httpRef = useRef(http);
httpRef.current = http;
const [data, setData] = useState<OverviewSparklines | null>(null);
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
const load = async () => {
try {
const now = new Date();
const from = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const [tsRes, sumRes] = await Promise.all([
httpRef.current.get<{ points: TimeSeriesPoint[] }>("/v1/usage/timeseries", {
from: from.toISOString(),
to: now.toISOString(),
group_by: "hour",
}),
httpRef.current.get<SummaryResponse>("/v1/usage/summary", { period: "today" }),
]);
const points = tsRes.points ?? [];
setData({
requestSparkline: points.map((p) => p.request_count),
tokenSparkline: points.map((p) => p.input_tokens + p.output_tokens),
costSparkline: points.map((p) => p.total_cost),
trends: {
requests: computeTrend(sumRes.current.requests, sumRes.previous.requests),
tokens: computeTrend(
sumRes.current.input_tokens + sumRes.current.output_tokens,
sumRes.previous.input_tokens + sumRes.previous.output_tokens,
),
cost: computeTrend(sumRes.current.cost, sumRes.previous.cost),
},
});
} catch {
// graceful degradation — no sparklines shown
}
};
load();
}, []);
return data;
}
@@ -23,6 +23,7 @@ import type {
} from "./types";
import { useLiveUptime } from "./hooks/use-live-uptime";
import { StatCard } from "./stat-card";
import { useOverviewSparklines } from "./hooks/use-overview-sparklines";
import { SystemHealthCard } from "./system-health-card";
import { ConnectedClientsCard } from "./connected-clients-card";
import { CronJobsCard } from "./cron-jobs-card";
@@ -40,6 +41,7 @@ export function OverviewPage() {
useWsCall<StatusPayload>(Methods.STATUS);
const { call: fetchQuota, data: quota } =
useWsCall<QuotaUsageResult>(Methods.QUOTA_USAGE);
const sparklines = useOverviewSparklines();
const { call: fetchCron, data: cronData } =
useWsCall<CronListPayload>(Methods.CRON_LIST);
const { call: fetchChannels, data: channelStatusData } =
@@ -142,6 +144,8 @@ export function OverviewPage() {
? t("statCards.users", { count: quota.uniqueUsersToday })
: undefined
}
sparkline={sparklines?.requestSparkline}
trend={sparklines?.trends.requests}
/>
<StatCard
icon={Hash}
@@ -154,11 +158,15 @@ export function OverviewPage() {
? t("statCards.inOut", { input: formatTokens(quota.inputTokensToday), output: formatTokens(quota.outputTokensToday) })
: undefined
}
sparkline={sparklines?.tokenSparkline}
trend={sparklines?.trends.tokens}
/>
<StatCard
icon={DollarSign}
label={t("statCards.costToday", "Cost Today")}
value={formatCost(quota?.costToday)}
sparkline={sparklines?.costSparkline}
trend={sparklines?.trends.cost}
/>
<StatCard
icon={Bot}
+51 -2
View File
@@ -1,26 +1,75 @@
import { AreaChart, Area, ResponsiveContainer } from "recharts";
import { TrendingUp, TrendingDown } from "lucide-react";
export function StatCard({
icon: Icon,
label,
value,
sub,
sparkline,
trend,
}: {
icon: React.ElementType;
label: string;
value: string | number;
sub?: string;
sparkline?: number[];
trend?: number | null;
}) {
const sparkData = sparkline?.map((v) => ({ v }));
const hasTrend = trend != null && trend !== 0;
return (
<div className="rounded-lg border bg-card p-5">
<div className="flex items-center gap-3">
<div className="rounded-md bg-muted p-2">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<div className="flex-1 min-w-0">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
<div className="flex items-center gap-2">
<p className="text-2xl font-semibold">{value}</p>
{hasTrend && (
<span
className={`flex items-center gap-0.5 text-xs font-medium ${
trend > 0 ? "text-green-600" : "text-red-600"
}`}
>
{trend > 0 ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{trend > 0 ? `+${trend}%` : `${trend}%`}
</span>
)}
</div>
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
</div>
</div>
{sparkData && sparkData.length > 1 && (
<div className="mt-3 h-[40px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={sparkData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="sparkGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-primary)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-primary)" stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="v"
stroke="var(--color-primary)"
strokeWidth={1.5}
fill="url(#sparkGrad)"
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
@@ -0,0 +1,58 @@
import { ReactNode } from "react";
import { BarChart3 } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
interface ChartWrapperProps {
title: string;
subtitle?: string;
loading?: boolean;
empty?: boolean;
emptyText?: string;
height?: number;
children: ReactNode;
className?: string;
actions?: ReactNode;
}
export function ChartWrapper({
title,
subtitle,
loading,
empty,
emptyText = "No data for selected period",
height = 300,
children,
className,
actions,
}: ChartWrapperProps) {
return (
<div className={cn("rounded-lg border bg-card p-4", className)}>
<div className="mb-4 flex items-start justify-between">
<div>
<h3 className="text-sm font-semibold">{title}</h3>
{subtitle && <p className="mt-0.5 text-xs text-muted-foreground">{subtitle}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
{loading ? (
<div className="space-y-2" style={{ height }}>
<Skeleton className="h-full w-full" />
</div>
) : empty ? (
<div
className="flex flex-col items-center justify-center text-center"
style={{ height }}
>
<div className="mb-2 rounded-full bg-muted p-2">
<BarChart3 className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground">{emptyText}</p>
</div>
) : (
children
)}
</div>
);
}
@@ -0,0 +1,105 @@
import { useTranslation } from "react-i18next";
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from "recharts";
import type { PieSectorDataItem } from "recharts/types/polar/Pie";
import { ChartWrapper } from "./chart-wrapper";
import type { SnapshotBreakdown } from "../hooks/use-usage-analytics";
const PALETTE = [
"#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
"#06b6d4", "#f97316", "#ec4899", "#84cc16", "#6366f1",
];
const MAX_SLICES = 8;
interface DistributionDonutProps {
title: string;
data: SnapshotBreakdown[];
loading?: boolean;
activeValue?: string;
onSliceClick?: (dimension: string) => void;
}
interface SliceEntry {
name: string;
value: number;
calls: number;
}
export function DistributionDonut({
title,
data,
loading,
activeValue,
onSliceClick,
}: DistributionDonutProps) {
const { t } = useTranslation("usage");
const isEmpty = !loading && data.length === 0;
const sorted = [...data].sort((a, b) => b.request_count - a.request_count);
const top = sorted.slice(0, MAX_SLICES);
const rest = sorted.slice(MAX_SLICES);
const slices: SliceEntry[] = top.map((d) => ({ name: d.key, value: d.request_count, calls: d.request_count }));
if (rest.length > 0) {
const otherCount = rest.reduce((sum, d) => sum + d.request_count, 0);
slices.push({ name: t("analytics.distribution.other"), value: otherCount, calls: otherCount });
}
const total = slices.reduce((sum, s) => sum + s.value, 0);
const handleClick = (entry: PieSectorDataItem) => {
const name = entry.name as string | undefined;
if (!name || name === t("analytics.distribution.other")) return;
onSliceClick?.(name);
};
return (
<ChartWrapper title={title} loading={loading} empty={isEmpty} emptyText={t("analytics.noData")} height={260}>
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={slices}
cx="50%"
cy="45%"
innerRadius={55}
outerRadius={85}
paddingAngle={2}
dataKey="value"
onClick={handleClick}
style={{ cursor: onSliceClick ? "pointer" : "default" }}
label={false}
>
{slices.map((entry, idx) => (
<Cell
key={entry.name}
fill={PALETTE[idx % PALETTE.length]}
stroke={activeValue === entry.name ? "#1d4ed8" : "transparent"}
strokeWidth={activeValue === entry.name ? 3 : 0}
opacity={activeValue && activeValue !== entry.name ? 0.5 : 1}
/>
))}
</Pie>
<text x="50%" y="44%" textAnchor="middle" dominantBaseline="middle" className="fill-foreground text-sm font-semibold">
{total.toLocaleString()}
</text>
<text x="50%" y="52%" textAnchor="middle" dominantBaseline="middle" className="fill-muted-foreground text-xs">
{t("analytics.distribution.calls")}
</text>
<Tooltip
formatter={(value, name) => {
const v = typeof value === "number" ? value : Number(value) || 0;
const pct = total > 0 ? ((v / total) * 100).toFixed(1) : "0";
return [`${v.toLocaleString()} (${pct}%)`, String(name)];
}}
/>
<Legend
iconType="circle"
iconSize={8}
formatter={(value: string) => <span className="text-xs">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</ChartWrapper>
);
}
@@ -0,0 +1,47 @@
import { useTranslation } from "react-i18next";
import { DistributionDonut } from "./distribution-donut";
import { useUsageFilterContext } from "../context/usage-filter-context";
import type { SnapshotBreakdown } from "../hooks/use-usage-analytics";
interface DistributionRowProps {
providerBreakdown: SnapshotBreakdown[];
modelBreakdown: SnapshotBreakdown[];
channelBreakdown: SnapshotBreakdown[];
loading?: boolean;
}
export function DistributionRow({
providerBreakdown,
modelBreakdown,
channelBreakdown,
loading,
}: DistributionRowProps) {
const { t } = useTranslation("usage");
const { filters, toggleFilter } = useUsageFilterContext();
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<DistributionDonut
title={t("analytics.distribution.provider")}
data={providerBreakdown}
loading={loading}
activeValue={filters.provider}
onSliceClick={(dim) => toggleFilter("provider", dim)}
/>
<DistributionDonut
title={t("analytics.distribution.model")}
data={modelBreakdown}
loading={loading}
activeValue={filters.model}
onSliceClick={(dim) => toggleFilter("model", dim)}
/>
<DistributionDonut
title={t("analytics.distribution.channel")}
data={channelBreakdown}
loading={loading}
activeValue={filters.channel}
onSliceClick={(dim) => toggleFilter("channel", dim)}
/>
</div>
);
}
@@ -0,0 +1,71 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Legend,
} from "recharts";
import { formatDuration, 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 DurationChartProps {
data: SnapshotTimeSeries[];
loading?: boolean;
granularity: "hour" | "day";
}
export function DurationChart({ data, loading, granularity }: DurationChartProps) {
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),
avg_duration_ms: d.avg_duration_ms,
errorRate: d.request_count > 0 ? +((d.error_count / d.request_count) * 100).toFixed(1) : 0,
})), [data, granularity, timezone]);
return (
<ChartWrapper
title={t("analytics.durationChart.title")}
loading={loading}
empty={isEmpty}
emptyText={t("analytics.noData")}
>
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={chartData} margin={{ top: 4, right: 40, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} tickLine={false} />
<YAxis yAxisId="left" tickFormatter={(v) => formatDuration(v)} tick={{ fontSize: 11 }} width={56} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11 }} width={36} tickFormatter={(v) => `${v}%`} />
<Tooltip
formatter={(value, name) => {
const v = typeof value === "number" ? value : Number(value);
const n = String(name);
if (n === t("analytics.durationChart.avgDuration")) return [formatDuration(v), n];
return [`${v}%`, n];
}}
/>
<Legend />
<Bar
yAxisId="left"
dataKey="avg_duration_ms"
name={t("analytics.durationChart.avgDuration")}
fill="#8b5cf6"
radius={[2, 2, 0, 0]}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="errorRate"
name={t("analytics.durationChart.errorRate")}
stroke="#ef4444"
strokeWidth={2}
dot={{ r: 3, fill: "#ef4444" }}
/>
</ComposedChart>
</ResponsiveContainer>
</ChartWrapper>
);
}
@@ -0,0 +1,143 @@
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUsageFilterContext, type Period } from "../context/usage-filter-context";
import type { SnapshotBreakdown } from "../hooks/use-usage-analytics";
interface FilterBarProps {
agents: { id: string; name: string }[];
providerBreakdown: SnapshotBreakdown[];
channelBreakdown: SnapshotBreakdown[];
onExportCsv?: () => void;
}
const PERIODS: { value: Period; labelKey: string }[] = [
{ value: "24h", labelKey: "analytics.period24h" },
{ value: "7d", labelKey: "analytics.period7d" },
{ value: "30d", labelKey: "analytics.period30d" },
];
export function FilterBar({ agents, providerBreakdown, channelBreakdown, onExportCsv }: FilterBarProps) {
const { t } = useTranslation("usage");
const { filters, setPeriod, setFilter, clearFilters, activeFilterCount } = useUsageFilterContext();
const chips: { label: string; onRemove: () => void }[] = [];
if (filters.provider) chips.push({ label: `provider: ${filters.provider}`, onRemove: () => setFilter("provider", undefined) });
if (filters.model) chips.push({ label: `model: ${filters.model}`, onRemove: () => setFilter("model", undefined) });
if (filters.channel) chips.push({ label: `channel: ${filters.channel}`, onRemove: () => setFilter("channel", undefined) });
if (filters.agentId) {
const name = agents.find((a) => a.id === filters.agentId)?.name ?? filters.agentId;
chips.push({ label: `agent: ${name}`, onRemove: () => setFilter("agentId", undefined) });
}
return (
<div className="rounded-lg border bg-card p-3 space-y-3">
{/* Top row: period + export */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
{PERIODS.map((p) => (
<Button
key={p.value}
size="sm"
variant={filters.period === p.value ? "default" : "outline"}
className="h-7 px-3 text-xs"
onClick={() => setPeriod(p.value)}
>
{t(p.labelKey)}
</Button>
))}
</div>
<div className="ml-auto flex items-center gap-2">
{/* Agent dropdown */}
{agents.length > 0 && (
<Select
value={filters.agentId ?? "__all__"}
onValueChange={(v) => setFilter("agentId", v === "__all__" ? undefined : v)}
>
<SelectTrigger className="h-7 w-40 text-xs">
<SelectValue placeholder={t("analytics.allAgents")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("analytics.allAgents")}</SelectItem>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Provider dropdown */}
{providerBreakdown.length > 0 && (
<Select
value={filters.provider ?? "__all__"}
onValueChange={(v) => setFilter("provider", v === "__all__" ? undefined : v)}
>
<SelectTrigger className="h-7 w-36 text-xs">
<SelectValue placeholder={t("analytics.allProviders")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("analytics.allProviders")}</SelectItem>
{providerBreakdown.map((b) => (
<SelectItem key={b.key} value={b.key}>{b.key}</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Channel dropdown */}
{channelBreakdown.length > 0 && (
<Select
value={filters.channel ?? "__all__"}
onValueChange={(v) => setFilter("channel", v === "__all__" ? undefined : v)}
>
<SelectTrigger className="h-7 w-36 text-xs">
<SelectValue placeholder={t("analytics.allChannels")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("analytics.allChannels")}</SelectItem>
{channelBreakdown.map((b) => (
<SelectItem key={b.key} value={b.key}>{b.key}</SelectItem>
))}
</SelectContent>
</Select>
)}
{onExportCsv && (
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onExportCsv}>
{t("analytics.exportCsv")}
</Button>
)}
</div>
</div>
{/* Active filter chips */}
{chips.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">{t("analytics.activeFilters")}:</span>
{chips.map((chip) => (
<Badge key={chip.label} variant="secondary" className="gap-1 text-xs">
{chip.label}
<button onClick={chip.onRemove} className="ml-0.5 hover:text-foreground">
<X className="h-3 w-3" />
</button>
</Badge>
))}
{activeFilterCount > 0 && (
<Button size="sm" variant="ghost" className="h-5 px-2 text-xs text-muted-foreground" onClick={clearFilters}>
{t("analytics.clearAll")}
</Button>
)}
</div>
)}
</div>
);
}
@@ -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 (
<ChartWrapper
title={t("analytics.knowledgeChart.title")}
loading={loading}
empty={false}
emptyText={t("analytics.noData")}
>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} tickLine={false} />
<YAxis tick={{ fontSize: 11 }} width={40} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="memory_docs" name={t("analytics.knowledgeChart.memoryDocs")} stroke="#3b82f6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="memory_chunks" name={t("analytics.knowledgeChart.memoryChunks")} stroke="#93c5fd" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="kg_entities" name={t("analytics.knowledgeChart.kgEntities")} stroke="#8b5cf6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="kg_relations" name={t("analytics.knowledgeChart.kgRelations")} stroke="#c4b5fd" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</ChartWrapper>
);
}
@@ -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 (
<ChartWrapper
title={t("analytics.requestChart.title")}
loading={loading}
empty={isEmpty}
emptyText={t("analytics.noData")}
>
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={chartData} margin={{ top: 4, right: 40, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} tickLine={false} />
<YAxis yAxisId="left" tick={{ fontSize: 11 }} width={40} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11 }} width={40} tickFormatter={(v) => `${v}`} />
<Tooltip
formatter={(value, name, props) => {
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}`}
/>
<Legend />
<Bar
yAxisId="left"
dataKey="request_count"
name={t("analytics.requestChart.requests")}
fill="#3b82f6"
radius={[2, 2, 0, 0]}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="error_count"
name={t("analytics.requestChart.errors")}
stroke="#ef4444"
strokeWidth={2}
dot={{ r: 3, fill: "#ef4444" }}
/>
</ComposedChart>
</ResponsiveContainer>
</ChartWrapper>
);
}
@@ -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 (
<div className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold" title={hint}>{value}</p>
{trend !== null ? (
<div className={`mt-1 flex items-center gap-1 text-xs ${isUp ? "text-green-600" : isDown ? "text-red-500" : "text-muted-foreground"}`}>
{isUp ? <TrendingUp className="h-3 w-3" /> : isDown ? <TrendingDown className="h-3 w-3" /> : <Minus className="h-3 w-3" />}
<span>
{isUp
? t("analytics.trendUp", { value: Math.abs(trend).toFixed(1) })
: isDown
? t("analytics.trendDown", { value: trend.toFixed(1) })
: "0%"}
</span>
<span className="text-muted-foreground">{t("analytics.vsPrevious")}</span>
</div>
) : (
<p className="mt-1 text-xs text-muted-foreground">{t("analytics.vsPrevious")}: N/A</p>
)}
</div>
);
}
export function SummaryCards({ current, previous, loading }: SummaryCardsProps) {
const { t } = useTranslation("usage");
if (loading) {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card p-4 animate-pulse">
<div className="h-3 w-20 rounded bg-muted mb-2" />
<div className="h-7 w-16 rounded bg-muted" />
</div>
))}
</div>
);
}
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 (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{cards.map((card) => (
<StatCard key={card.label} {...card} />
))}
</div>
);
}
@@ -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 (
<ChartWrapper
title={t("analytics.tokenChart.title")}
loading={loading}
empty={isEmpty}
emptyText={t("analytics.noData")}
>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={chartData} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="inputGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id="outputGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} tickLine={false} />
<YAxis tickFormatter={(v) => formatTokens(v)} tick={{ fontSize: 11 }} width={52} />
<Tooltip
formatter={(value, name) => [formatTokens(typeof value === "number" ? value : Number(value)), String(name)]}
labelFormatter={(label) => `${t("analytics.tooltip.date")}: ${label}`}
/>
<Legend />
<Area
type="monotone"
dataKey="input_tokens"
name={t("analytics.tokenChart.input")}
stroke="#3b82f6"
fill="url(#inputGrad)"
strokeWidth={2}
stackId="tokens"
/>
<Area
type="monotone"
dataKey="output_tokens"
name={t("analytics.tokenChart.output")}
stroke="#10b981"
fill="url(#outputGrad)"
strokeWidth={2}
stackId="tokens"
/>
{hasCache && (
<Area
type="monotone"
dataKey="cache_read_tokens"
name={t("analytics.tokenChart.cache")}
stroke="#06b6d4"
fill="none"
strokeWidth={1.5}
strokeDasharray="4 2"
/>
)}
<Brush dataKey="label" height={20} stroke="#e5e7eb" />
</AreaChart>
</ResponsiveContainer>
</ChartWrapper>
);
}
@@ -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<SortKey>("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 <ArrowUpDown className="ml-1 h-3 w-3 opacity-40" />;
return sortDir === "desc"
? <ArrowDown className="ml-1 h-3 w-3" />
: <ArrowUp className="ml-1 h-3 w-3" />;
}
function ThSort({ col, label }: { col: SortKey; label: string }) {
return (
<th
className="px-3 py-2 text-right font-medium cursor-pointer select-none hover:text-foreground whitespace-nowrap"
onClick={() => handleSort(col)}
>
<span className="inline-flex items-center justify-end">
{label}
<SortIcon col={col} />
</span>
</th>
);
}
if (loading) {
return (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-3 text-sm font-semibold">{t("analytics.topModels.title")}</h3>
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
))}
</div>
</div>
);
}
if (data.length === 0) return null;
return (
<div className="rounded-lg border bg-card overflow-hidden">
<div className="px-4 py-3 border-b">
<h3 className="text-sm font-semibold">{t("analytics.topModels.title")}</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-xs text-muted-foreground">
<th className="px-3 py-2 text-left font-medium">{t("analytics.topModels.model")}</th>
<th className="px-3 py-2 text-left font-medium">{t("analytics.topModels.provider")}</th>
<ThSort col="llm_call_count" label={t("analytics.topModels.llmCalls")} />
<ThSort col="input_tokens" label={t("analytics.topModels.inputTokens")} />
<ThSort col="output_tokens" label={t("analytics.topModels.outputTokens")} />
<ThSort col="avg_duration_ms" label={t("analytics.topModels.avgDuration")} />
<ThSort col="total_cost" label={t("analytics.topModels.cost")} />
</tr>
</thead>
<tbody>
{sorted.map((row) => {
const isActive = filters.model === row.key;
const [model, provider] = row.key.includes("/")
? row.key.split("/", 2)
: [row.key, "—"];
return (
<tr
key={row.key}
className={cn(
"border-b last:border-0 hover:bg-muted/30 cursor-pointer transition-colors",
isActive && "bg-primary/5",
)}
onClick={() => toggleFilter("model", row.key)}
>
<td className="px-3 py-2 font-medium">{model}</td>
<td className="px-3 py-2 text-muted-foreground">{provider}</td>
<td className="px-3 py-2 text-right">{row.llm_call_count.toLocaleString()}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{formatTokens(row.input_tokens)}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{formatTokens(row.output_tokens)}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{formatDuration(row.avg_duration_ms)}</td>
<td className="px-3 py-2 text-right">{formatCost(row.total_cost)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -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<UsageFilterContextValue | null>(null);
export function UsageFilterProvider({ children }: { children: ReactNode }) {
const [filters, setFilters] = useState<UsageFilters>(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 (
<UsageFilterContext.Provider value={{ filters, setFilter, toggleFilter, setPeriod, clearFilters, activeFilterCount }}>
{children}
</UsageFilterContext.Provider>
);
}
export function useUsageFilterContext(): UsageFilterContextValue {
const ctx = useContext(UsageFilterContext);
if (!ctx) throw new Error("useUsageFilterContext must be used within UsageFilterProvider");
return ctx;
}
@@ -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<string, string>): Record<string, string> {
const p: Record<string, string> = {
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<SummaryResponse>("/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,
};
}
+150 -115
View File
@@ -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 (
<div className="p-4 sm:p-6">
<div className="p-4 sm:p-6 space-y-4">
<PageHeader
title={t("title")}
title={t("analytics.title")}
description={t("description")}
actions={
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {t("common:refresh", "Refresh")}
<Button variant="outline" size="sm" onClick={() => loadRecords()} disabled={recLoading} className="gap-1">
<RefreshCw className={`h-3.5 w-3.5${recLoading ? " animate-spin" : ""}`} />
{t("common:refresh", "Refresh")}
</Button>
}
/>
{showSkeleton ? (
<div className="mt-6">
<TableSkeleton rows={4} />
</div>
) : (
<>
{/* Summary cards */}
{summary && agentEntries.length > 0 && (
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{agentEntries.map(([agentId, data]) => (
<div key={agentId} className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">{agentId}</h4>
<Badge variant="secondary">{t("summary.sessions", { count: data.sessions })}</Badge>
</div>
<div className="mt-3 space-y-1 text-sm text-muted-foreground">
<div className="flex justify-between">
<span>{t("summary.inputTokens")}</span>
<span className="font-medium text-foreground">{formatTokens(data.inputTokens)}</span>
</div>
<div className="flex justify-between">
<span>{t("summary.outputTokens")}</span>
<span className="font-medium text-foreground">{formatTokens(data.outputTokens)}</span>
</div>
<div className="flex justify-between border-t pt-1">
<span>{t("summary.total")}</span>
<span className="font-medium text-foreground">{formatTokens(data.totalTokens)}</span>
</div>
</div>
</div>
))}
</div>
)}
<FilterBar
agents={agentList}
providerBreakdown={providerBreakdown}
channelBreakdown={channelBreakdown}
onExportCsv={timeseries.length > 0 ? handleExportCsv : undefined}
/>
{/* Recent records table */}
<div className="mt-6">
<h3 className="mb-3 text-sm font-medium">{t("recentRecords")}</h3>
{records.length === 0 ? (
<EmptyState
icon={BarChart3}
title={t("emptyTitle")}
description={t("emptyDescription")}
/>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t("columns.agent")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.model")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.provider")}</th>
<th className="px-4 py-3 text-right font-medium">{t("columns.input")}</th>
<th className="px-4 py-3 text-right font-medium">{t("columns.output")}</th>
<th className="px-4 py-3 text-right font-medium">{t("columns.total")}</th>
</tr>
</thead>
<tbody>
{records.map((r, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{r.agentId}</td>
<td className="px-4 py-3">
<Badge variant="outline">{r.model}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">{r.provider}</td>
<td className="px-4 py-3 text-right text-muted-foreground">
{formatTokens(r.inputTokens)}
</td>
<td className="px-4 py-3 text-right text-muted-foreground">
{formatTokens(r.outputTokens)}
</td>
<td className="px-4 py-3 text-right font-medium">
{formatTokens(r.totalTokens)}
</td>
</tr>
))}
</tbody>
</table>
<Pagination
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={setPage}
onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
/>
</div>
)}
</div>
</>
{apiError && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{t("common:error", "Error")}: {apiError}
</div>
)}
<SummaryCards current={current} previous={previous} loading={loading} />
<TokenAreaChart data={timeseries} loading={loading} granularity={filters.granularity} />
<RequestVolumeChart data={timeseries} loading={loading} granularity={filters.granularity} />
<DistributionRow
providerBreakdown={providerBreakdown}
modelBreakdown={modelBreakdown}
channelBreakdown={channelBreakdown}
loading={loading}
/>
<DurationChart data={timeseries} loading={loading} granularity={filters.granularity} />
<KnowledgeChart data={timeseries} loading={loading} granularity={filters.granularity} />
<TopModelsTable data={modelBreakdown} loading={loading} />
{/* Legacy records table */}
<div>
<h3 className="mb-3 text-sm font-semibold">{t("recentRecords")}</h3>
{records.length === 0 && !recLoading ? (
<EmptyState icon={BarChart3} title={t("emptyTitle")} description={t("emptyDescription")} />
) : (
<div className="rounded-md border">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t("columns.agent")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.model")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.provider")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.channel")}</th>
<th className="px-4 py-3 text-right font-medium">{t("columns.input")}</th>
<th className="px-4 py-3 text-right font-medium">{t("columns.output")}</th>
<th className="px-4 py-3 text-right font-medium">{t("columns.cost")}</th>
</tr>
</thead>
<tbody>
{records.map((r, i) => (
<tr
key={i}
className="border-b last:border-0 hover:bg-muted/30 cursor-pointer"
onClick={() => setFilter("agentId", r.agentId)}
>
<td className="px-4 py-3 font-medium">{r.agentId}</td>
<td className="px-4 py-3"><Badge variant="outline">{r.model}</Badge></td>
<td className="px-4 py-3 text-muted-foreground">{r.provider}</td>
<td className="px-4 py-3 text-muted-foreground"></td>
<td className="px-4 py-3 text-right text-muted-foreground">{formatTokens(r.inputTokens)}</td>
<td className="px-4 py-3 text-right text-muted-foreground">{formatTokens(r.outputTokens)}</td>
<td className="px-4 py-3 text-right">{formatCost(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={setPage}
onPageSizeChange={() => {}}
/>
</div>
)}
</div>
</div>
);
}
export function UsagePage() {
return (
<UsageFilterProvider>
<AnalyticsDashboard />
</UsageFilterProvider>
);
}
+8
View File
@@ -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<UiState>((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<UiState>((set) => ({
set({ language });
},
setTimezone: (tz) => {
localStorage.setItem(LOCAL_STORAGE_KEYS.TIMEZONE, tz);
set({ timezone: tz });
},
toggleSidebar: () =>
set((state) => {
const next = !state.sidebarCollapsed;