| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strconv" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/config" |
| 10 | "reasonix/internal/stats" |
| 11 | ) |
| 12 | |
| 13 | // UsageStatsRequest asks for the usage statistics panel aggregate. |
| 14 | // Range days: 7 / 14 / 30 / 90, or a custom From/To pair (inclusive, local |
| 15 | // dates). Source "" or "all" aggregates every entry point (desktop, cli, serve, |
| 16 | // bot, remote); any other value filters to that source's records. |
| 17 | type UsageStatsRequest struct { |
| 18 | Range string `json:"range"` // "7" | "14" | "30" | "90" | "custom" |
| 19 | From string `json:"from,omitempty"` // "2006-01-02", custom only |
| 20 | To string `json:"to,omitempty"` |
| 21 | Source string `json:"source,omitempty"` // "" | "all" | "desktop" | "cli" | "serve" | "bot" | "remote" |
| 22 | } |
| 23 | |
| 24 | // UsageStatsRange is the aggregate response. Fields map 1:1 to the settings |
| 25 | // panel sections (totals, derived stats, daily trend, per-model split). |
| 26 | type UsageStatsRange struct { |
| 27 | From string `json:"from"` |
| 28 | To string `json:"to"` |
| 29 | Tokens int64 `json:"tokens"` |
| 30 | Requests int `json:"requests"` |
| 31 | Turns int `json:"turns"` |
| 32 | CacheHit int64 `json:"cacheHit"` |
| 33 | CacheMiss int64 `json:"cacheMiss"` |
| 34 | ActiveDays int `json:"activeDays"` |
| 35 | TopModel string `json:"topModel"` |
| 36 | TopProvider string `json:"topProvider"` |
| 37 | Daily []stats.DailyTokens `json:"daily"` |
| 38 | Models []stats.ModelUsage `json:"models"` |
| 39 | Providers []stats.ProviderUsage `json:"providers"` |
| 40 | } |
| 41 | |
| 42 | // UsageStats aggregates recorded usage over the requested range. It is a pure |
| 43 | // read of the stats files under the user state root; it never blocks on or |
| 44 | // mutates any active controller. An empty stats dir yields an all-zero range. |
| 45 | func (a *App) UsageStats(req UsageStatsRequest) (UsageStatsRange, error) { |
| 46 | from, to, err := resolveStatsRange(req) |
| 47 | if err != nil { |
| 48 | return UsageStatsRange{}, err |
| 49 | } |
| 50 | // Recording is asynchronous so chat completion never waits for disk. Give the |
| 51 | // settings-only read a short read-your-own-writes window; lock contention may |
| 52 | // return slightly stale statistics, but cannot stall the chat runtime. |
| 53 | flushCtx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) |
| 54 | _ = stats.Flush(flushCtx, config.StatsDir()) |
| 55 | cancel() |
| 56 | w := stats.NewWriter(config.StatsDir()) |
| 57 | res, err := w.Query(stats.SourceFilter{From: from, To: to, Source: req.Source}) |
| 58 | if err != nil { |
| 59 | return UsageStatsRange{}, err |
| 60 | } |
| 61 | return UsageStatsRange{ |
| 62 | From: res.From, |
| 63 | To: res.To, |
| 64 | Tokens: res.Tokens, |
| 65 | Requests: res.Requests, |
| 66 | Turns: res.Turns, |
| 67 | CacheHit: res.CacheHit, |
| 68 | CacheMiss: res.CacheMiss, |
| 69 | ActiveDays: res.ActiveDays, |
| 70 | TopModel: res.TopModel, |
| 71 | TopProvider: res.TopProvider, |
| 72 | Daily: res.Daily, |
| 73 | Models: res.Models, |
| 74 | Providers: res.Providers, |
| 75 | }, nil |
| 76 | } |
| 77 | |
| 78 | const ( |
| 79 | dateLayout = "2006-01-02" |
| 80 | maxStatsCustomRangeDays = 3660 |
| 81 | ) |
| 82 | |
| 83 | // resolveStatsRange maps a request's Range into an inclusive [from, to] pair. |
| 84 | // "custom" requires valid From/To dates; the presets end today. |
| 85 | func resolveStatsRange(req UsageStatsRequest) (from, to time.Time, err error) { |
| 86 | now := time.Now() |
| 87 | to = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) |
| 88 | switch req.Range { |
| 89 | case "7", "14", "30", "90": |
| 90 | n, err := strconv.Atoi(req.Range) |
| 91 | if err != nil || n <= 0 { |
| 92 | n = 7 |
| 93 | } |
| 94 | from = to.AddDate(0, 0, -(n - 1)) |
| 95 | from = time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, now.Location()) |
| 96 | case "custom": |
| 97 | f, ferr := time.ParseInLocation(dateLayout, req.From, now.Location()) |
| 98 | t, terr := time.ParseInLocation(dateLayout, req.To, now.Location()) |
| 99 | if ferr != nil || terr != nil { |
| 100 | return time.Time{}, time.Time{}, fmt.Errorf("usage stats: custom range needs valid from/to dates (2006-01-02)") |
| 101 | } |
| 102 | if t.Before(f) { |
| 103 | return time.Time{}, time.Time{}, fmt.Errorf("usage stats: custom range from date must not be after to date") |
| 104 | } |
| 105 | today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) |
| 106 | if t.After(today) { |
| 107 | return time.Time{}, time.Time{}, fmt.Errorf("usage stats: custom range to date must not be in the future") |
| 108 | } |
| 109 | fUTC := time.Date(f.Year(), f.Month(), f.Day(), 0, 0, 0, 0, time.UTC) |
| 110 | tUTC := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) |
| 111 | if days := int(tUTC.Sub(fUTC)/(24*time.Hour)) + 1; days > maxStatsCustomRangeDays { |
| 112 | return time.Time{}, time.Time{}, fmt.Errorf("usage stats: custom range cannot exceed %d days", maxStatsCustomRangeDays) |
| 113 | } |
| 114 | from = time.Date(f.Year(), f.Month(), f.Day(), 0, 0, 0, 0, now.Location()) |
| 115 | to = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, now.Location()) |
| 116 | default: |
| 117 | // Unknown/empty range defaults to the last 7 days. |
| 118 | from = to.AddDate(0, 0, -6) |
| 119 | from = time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, now.Location()) |
| 120 | } |
| 121 | return from, to, nil |
| 122 | } |
| 123 |