mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-02 08:20:41 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -24,4 +24,5 @@ type Stores struct {
|
||||
KnowledgeGraph KnowledgeGraphStore
|
||||
Contacts ContactStore
|
||||
Activity ActivityStore
|
||||
Snapshots SnapshotStore
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
Generated
+281
-6
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}%"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"collapseSidebar": "收起侧边栏",
|
||||
"expandSidebar": "展开侧边栏",
|
||||
"language": "语言",
|
||||
"timezone": "时区",
|
||||
"languages": {
|
||||
"en": "English",
|
||||
"vi": "Tiếng Việt",
|
||||
|
||||
@@ -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}}%"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user