Files
llmgw/internal/web/admin_usage.go
T
Ilker ManapandClaude Opus 5.5 c9cd25efc2 Draw one menu on every page
Each page wrote its own nav: the same links in a different order, some
missing, the current page sometimes listed and sometimes not. A shared
"nav" template now draws it everywhere — Cluster, People, Quotas, Usage,
Mirror for an administrator, then Chat, Your keys and Sign out — with
the current page marked by aria-current. Pages rendered outside the
account pages (the dashboard, node logs) get it through the request
context their admin wrapper sets. The admin listener's own page keeps
its metrics/pprof/api links.

The chat and key pages said "project proj-3kq7…"; they now show the
project's path, and the key page no longer repeats the email.

A test renders several admin pages and requires the same menu on each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:55:36 +02:00

210 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 {
Nav Nav
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{
Nav: navFrom(r, "usage"),
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)
}
}