package web import ( "context" "net/http" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/promq" "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) // UsageAdmin is the store half of the usage report. type UsageAdmin interface { UsageBucketsBy(ctx context.Context, g store.Grouping, since time.Time, width time.Duration) ([]store.UsageSeries, error) } // usageWindow is one length of history the page offers, and the slice it is cut // into. // // The bucket width is chosen with the window rather than separately. A day in // five-minute slices is 288 points across 720 pixels, which is a texture; a // month in five-minute slices is 8,640, which is a smear. Roughly fifty to a // hundred points is what a line of this size can actually show. type usageWindow struct { Key string Label string Span time.Duration Width time.Duration } var usageWindows = []usageWindow{ {"24h", "last 24 hours", 24 * time.Hour, 20 * time.Minute}, {"7d", "last 7 days", 7 * 24 * time.Hour, 2 * time.Hour}, {"30d", "last 30 days", 30 * 24 * time.Hour, 12 * time.Hour}, } func windowFor(key string) usageWindow { for _, w := range usageWindows { if w.Key == key { return w } } return usageWindows[1] } // usageGroup names one grouping for the chooser. type usageGroup struct { Key string Label string Selected bool } // usageRow is one group's totals, shown under the charts. type usageRow struct { Name string Requests int64 Tokens int64 Spend string Errors int64 } type adminUsageData struct { Groups []usageGroup Windows []struct { Key, Label string Selected bool } By string Window string Requests TimeSeries Tokens TimeSeries Spend TimeSeries Rows []usageRow // Priced is false when no model has a price, which makes the spend chart a // flat zero however busy the cluster is. Said rather than drawn, because a // chart along the bottom reads as "nobody spent anything". Priced bool Error string Version string Now string } // RegisterAdminUsage mounts the usage report under /admin/usage. // // Separate from the quotas page although both read the same tree. Quotas is a // page for writing a number and seeing whether it is being approached; this one // answers "who is using the cluster, and when" — including per person, which is // not a level quotas can be written on at all. func (p *AccountPages) RegisterAdminUsage(mux *http.ServeMux, s UsageAdmin, priced Priced) { mux.HandleFunc("GET /admin/usage", p.AdminPage( func(w http.ResponseWriter, r *http.Request) { p.renderUsage(w, r, s, priced) })) } func (p *AccountPages) renderUsage(w http.ResponseWriter, r *http.Request, s UsageAdmin, priced Priced, ) { by := store.Grouping(r.URL.Query().Get("by")) if !by.Valid() { by = store.GroupByTeam } win := windowFor(r.URL.Query().Get("window")) d := adminUsageData{ By: string(by), Window: win.Key, Priced: priced != nil && priced.AnythingPriced(), Version: p.version, Now: time.Now().UTC().Format(time.RFC3339), } for _, g := range store.Groupings() { d.Groups = append(d.Groups, usageGroup{ Key: string(g), Label: groupLabel(g), Selected: g == by, }) } for _, uw := range usageWindows { d.Windows = append(d.Windows, struct { Key, Label string Selected bool }{uw.Key, uw.Label, uw.Key == win.Key}) } rows, err := s.UsageBucketsBy(r.Context(), by, time.Now().Add(-win.Span), win.Width) if err != nil { p.log.Error("could not read usage over time", "by", by, "err", err) d.Error = "could not read usage" render(w, "admin_usage", d) return } d.Requests = LineChartTotals(seriesOf(rows, func(b store.Bucket) float64 { return float64(b.Requests) }), "", 4) d.Tokens = LineChartTotals(seriesOf(rows, func(b store.Bucket) float64 { return float64(b.Prompt + b.Completion) }), "", 4) d.Spend = LineChartTotals(seriesOf(rows, func(b store.Bucket) float64 { // Shown in whole units, as every other spend figure is: a budget is // written in units and a chart in micros would not compare with it. return float64(b.CostMicros) / 1e6 }), "", 4) for _, u := range rows { d.Rows = append(d.Rows, usageRow{ Name: displayName(u), Requests: u.Requests, Tokens: u.Tokens(), Spend: unitsOf(u.CostMicros), Errors: u.Errors, }) } render(w, "admin_usage", d) } // seriesOf turns each group's buckets into a line of one measure. // // One pass per measure rather than one query per measure: the buckets are // already in memory and already share a clock, and three queries that could // disagree about the window is a worse answer than three loops. func seriesOf(rows []store.UsageSeries, value func(store.Bucket) float64) []promq.Series { out := make([]promq.Series, 0, len(rows)) for _, u := range rows { s := promq.Series{Name: displayName(u)} for _, b := range u.Buckets { s.Points = append(s.Points, promq.Point{At: b.Start, Value: value(b)}) } out = append(out, s) } return out } // displayName is what a line and a row are labelled with. // // A person with no account is named rather than left blank: keys issued before // accounts existed carry real traffic, and an unlabelled line in a legend is // the one nobody can act on. func displayName(u store.UsageSeries) string { if u.Name != "" { return u.Name } if u.Grouping == store.GroupByUser { return "keys with no owner" } if u.ID != "" { return u.ID } return "unknown" } func groupLabel(g store.Grouping) string { switch g { case store.GroupByOrg: return "organization" case store.GroupByTeam: return "team" case store.GroupByProject: return "project" case store.GroupByUser: return "person" default: return string(g) } }