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>
165 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|