Files
llmgw/internal/web/admin_usage_test.go
T
Ilker ManapandClaude Opus 5 07809490e3 Show usage over time, per organization, team, project and person
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>
2026-09-28 08:11:31 +02:00

165 lines
5.4 KiB
Go

package web
import (
"context"
"io"
"log/slog"
"net/http"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
type fakeScopeSeries struct {
byGroup map[store.Grouping][]store.UsageSeries
asked []store.Grouping
width time.Duration
since time.Time
}
func (f *fakeScopeSeries) UsageBucketsBy(_ context.Context, g store.Grouping,
since time.Time, width time.Duration,
) ([]store.UsageSeries, error) {
f.asked = append(f.asked, g)
f.width, f.since = width, since
return f.byGroup[g], nil
}
func usagePage(t *testing.T, s *fakeScopeSeries, priced Priced) *http.ServeMux {
t.Helper()
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
mux := http.NewServeMux()
p.RegisterAdminUsage(mux, s, priced)
return mux
}
// hourly builds a group whose traffic rises over six slices, so a chart drawn
// from it has a shape rather than a dot.
func hourly(g store.Grouping, id, name string, scale int64) store.UsageSeries {
u := store.UsageSeries{Grouping: g, ID: id, Name: name}
base := time.Now().Add(-6 * time.Hour).Truncate(time.Hour)
for i := int64(1); i <= 6; i++ {
b := store.Bucket{
Start: base.Add(time.Duration(i) * time.Hour),
Requests: i * scale,
Prompt: i * scale * 100,
Completion: i * scale * 20,
CostMicros: i * scale * 1000,
}
u.Buckets = append(u.Buckets, b)
u.Requests += b.Requests
u.Prompt += b.Prompt
u.Completion += b.Completion
u.CostMicros += b.CostMicros
}
return u
}
func TestTheUsagePageDrawsAndTabulatesEachGroup(t *testing.T) {
s := &fakeScopeSeries{byGroup: map[store.Grouping][]store.UsageSeries{
store.GroupByTeam: {
hourly(store.GroupByTeam, "t1", "platform", 3),
hourly(store.GroupByTeam, "t2", "research", 1),
},
}}
body := mirrorGet(t, usagePage(t, s, pricedYes{}), "/admin/usage").Body.String()
for _, want := range []string{"platform", "research"} {
if !strings.Contains(body, want) {
t.Errorf("%q is missing from the page", want)
}
}
// A chart is an SVG with lines in it, not a frame saying there is nothing.
if strings.Count(body, "<svg") < 3 {
t.Errorf("expected a chart for requests, tokens and spend; got %s", body)
}
if strings.Contains(body, "Nothing recorded in this window") {
t.Error("the table was left out although there are two groups")
}
}
func TestTeamIsTheDefaultGrouping(t *testing.T) {
// The middle of the tree: an organization is usually one on a first
// cluster, and a project list is long before it is informative.
s := &fakeScopeSeries{}
mirrorGet(t, usagePage(t, s, pricedYes{}), "/admin/usage")
if len(s.asked) != 1 || s.asked[0] != store.GroupByTeam {
t.Errorf("asked for %v, expected one query grouped by team", s.asked)
}
}
func TestTheGroupingComesFromTheURL(t *testing.T) {
s := &fakeScopeSeries{}
mirrorGet(t, usagePage(t, s, pricedYes{}), "/admin/usage?by=user")
if len(s.asked) != 1 || s.asked[0] != store.GroupByUser {
t.Errorf("asked for %v, expected a query grouped by user", s.asked)
}
}
func TestAnUnknownGroupingFallsBackRatherThanFailing(t *testing.T) {
// The grouping reaches SQL, so it is validated before it is used. A page
// that 500s on a mistyped query string is worse than one that shows the
// default, and the chooser makes the mistake obvious.
s := &fakeScopeSeries{}
rec := mirrorGet(t, usagePage(t, s, pricedYes{}), "/admin/usage?by=drop+table")
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
if len(s.asked) != 1 || s.asked[0] != store.GroupByTeam {
t.Errorf("asked for %v, expected the default", s.asked)
}
}
func TestTheWindowChoosesItsOwnBucketWidth(t *testing.T) {
// A month in twenty-minute slices is two thousand points across seven
// hundred pixels, which is a smear rather than a chart.
s := &fakeScopeSeries{}
mirrorGet(t, usagePage(t, s, pricedYes{}), "/admin/usage?window=30d")
wide := s.width
s2 := &fakeScopeSeries{}
mirrorGet(t, usagePage(t, s2, pricedYes{}), "/admin/usage?window=24h")
if !(wide > s2.width) {
t.Errorf("a 30-day window bucketed at %s, a 24-hour one at %s", wide, s2.width)
}
if s2.width > time.Hour {
t.Errorf("a day in %s slices is too coarse to read", s2.width)
}
}
func TestWithNoPricesTheSpendChartIsReplacedBySayingWhy(t *testing.T) {
s := &fakeScopeSeries{byGroup: map[store.Grouping][]store.UsageSeries{
store.GroupByTeam: {hourly(store.GroupByTeam, "t1", "platform", 3)},
}}
body := mirrorGet(t, usagePage(t, s, pricedNo{}), "/admin/usage").Body.String()
// A spend chart flat along the bottom reads as a quiet month rather than as
// a cluster where nothing is priced.
if !strings.Contains(body, "No model has a price") {
t.Errorf("the page should say why spend is empty; got %s", body)
}
if strings.Count(body, "<svg") > 2 {
t.Error("a spend chart was drawn although nothing is priced")
}
}
func TestAPersonWithNoAccountIsNamedRatherThanBlank(t *testing.T) {
// Keys issued by an operator before accounts existed carry real traffic,
// and an unlabelled line in a legend is the one nobody can act on.
s := &fakeScopeSeries{byGroup: map[store.Grouping][]store.UsageSeries{
store.GroupByUser: {hourly(store.GroupByUser, "", "", 2)},
}}
body := mirrorGet(t, usagePage(t, s, pricedYes{}), "/admin/usage?by=user").Body.String()
if !strings.Contains(body, "keys with no owner") {
t.Errorf("unowned traffic was left unlabelled; got %s", body)
}
}