Usage could be read as a total — per key, per user, per model, per group — and the cluster's own charts covered the fleet's hardware. What nobody could see was when a group used it. "Research doubled its traffic three weeks ago" is not a question a column of totals can be asked, and it is the question somebody asks before a quota conversation. /admin/usage draws requests, tokens and spend over a chosen window, grouped by any level of the tenancy tree or by person. Person is not a level of that tree — one person holds keys in several projects — so the grouping is its own type rather than store.Scope with a fourth value nothing else could use. Three things learned from the charts already here. Buckets are gap-filled per group, because a line drawn straight across an absence claims traffic that did not happen, and because the chart sums its tail by timestamp and can only do that if every series shares a clock. Series are ranked by total rather than by peak — a counter's question is "who used the most", and ranking by peak would colour the team that made one burst over the team that ran steadily all week. And the spend chart is replaced by a sentence when no model has a price, rather than drawn flat along the bottom. The bucket query groups by ordinal and was planned against the cluster's PostgreSQL before being believed, with a test alongside the others that run there: three reports have shipped working on SQLite and failing on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
208 lines
5.6 KiB
Go
208 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|