Files
Ilker ManapandClaude Opus 5.5 11231d8c2b The portal in English and Turkish: a catalogue per language, a switcher at the foot of every page, landing.language, Turkish numbers and dates
Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:12:53 +02:00

323 lines
9.9 KiB
Go

package web
import (
"context"
"net/http"
"sort"
"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)
}
// StatsReader is how each group fared and how long its requests took; the
// store implements it. Optional: without it the usage table has no such
// columns.
type StatsReader interface {
StatsBy(ctx context.Context, g store.Grouping, since time.Time) (map[string]store.GroupStats, error)
}
// pctile is a percentile from the usage histograms as a person reads it:
// "180 ms", "4.2 s", "> 120 s" past the last bucket, "—" with nothing to read.
func pctile(q store.Quantiles, which int) string { return english.pctile(q, which) }
// pctile is the package's pctile in this language's numbers.
func (l *Lang) pctile(q store.Quantiles, which int) string {
if q.N == 0 {
return "—"
}
v := q.P50
switch which {
case 95:
v = q.P95
case 99:
v = q.P99
if q.Over {
return "> " + l.T("unit.s", l.Int(v/1000))
}
}
if v < 1000 {
return l.T("unit.ms", l.Int(v))
}
return l.T("unit.s", l.Num(float64(v)/1000, 1))
}
// 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.
//
// Its name on the page is the catalogue's usage.window.KEY.
type usageWindow struct {
Key string
Span time.Duration
Width time.Duration
}
var usageWindows = []usageWindow{
{"24h", 24 * time.Hour, 20 * time.Minute},
{"7d", 7 * 24 * time.Hour, 2 * time.Hour},
{"30d", 30 * 24 * time.Hour, 12 * time.Hour},
}
func init() {
registerKeys("usage.window.24h", "usage.window.7d", "usage.window.30d",
"usage.group.org", "usage.group.team", "usage.group.project", "usage.group.user", "usage.group.model")
}
// windowChoices are the windows as the chooser offers them, in l.
func windowChoices(l *Lang, selected string) []windowChoice {
out := make([]windowChoice, 0, len(usageWindows))
for _, uw := range usageWindows {
out = append(out, windowChoice{uw.Key, l.T("usage.window." + uw.Key), uw.Key == selected})
}
return out
}
// windowChoice is one window in the chooser.
type windowChoice struct {
Key, Label string
Selected bool
}
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 {
ID string
Name string
Requests int64
Tokens int64
Spend string
Errors int64
// Stats is how its requests ended and how long they took; HasStats is
// false where the store does not say.
Stats store.GroupStats
HasStats bool
}
type adminUsageData struct {
Nav Nav
Groups []usageGroup
Windows []windowChoice
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
// GPUHeld and GPUHours are the cards each owner's models held over the
// window, from the metrics store; empty without one, or with no owned
// model on a GPU.
GPUHeld TimeSeries
GPUHours []gpuHoursRow
// HasStats adds outcomes and latency percentiles to the table.
HasStats 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) {
p.usageStore, p.usagePriced = s, priced
mux.HandleFunc("GET /admin/usage", p.adminOnly(
func(w http.ResponseWriter, r *http.Request, admin store.User) {
p.renderPeople(w, r, admin, peopleView{Tab: tabUsage})
}))
}
// buildUsage is the cluster's use over time, as the usage tab shows it.
func (p *AccountPages) buildUsage(r *http.Request, s UsageAdmin, priced Priced) adminUsageData {
by := store.Grouping(r.URL.Query().Get("by"))
if !by.Valid() {
by = store.GroupByTeam
}
win := windowFor(r.URL.Query().Get("window"))
l := langOf(r)
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(l, g), Selected: g == by,
})
}
d.Windows = windowChoices(l, 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 = l.T("usage.err.read")
return d
}
d.Requests, d.Tokens, d.Spend, d.Rows = usageChartsIn(l, rows)
if sr, ok := s.(StatsReader); ok {
stats, err := sr.StatsBy(r.Context(), by, time.Now().Add(-win.Span))
if err != nil {
p.log.Warn("could not read latency and outcomes for the usage tab", "by", by, "err", err)
}
for i := range d.Rows {
d.Rows[i].Stats, d.Rows[i].HasStats = stats[d.Rows[i].ID], stats != nil
}
d.HasStats = stats != nil
}
d.GPUHeld, d.GPUHours = p.gpuHours(r.Context(), l, win)
return d
}
// gpuHoursRow is the GPU time one owner's models held over the window.
type gpuHoursRow struct {
Owner string
Hours float64
// Now is how many cards they hold at the end of the window.
Now float64
}
// gpuHours is the cards held by each owner's models over the window, drawn
// and summed into GPU-hours. From the metrics store, because what a model
// holds is placement's, not the request log's: a model nobody asks still
// holds its cards. As far back as the store keeps history, which for a
// default Prometheus is fifteen days.
func (p *AccountPages) gpuHours(ctx context.Context, l *Lang, win usageWindow) (TimeSeries, []gpuHoursRow) {
if p.history == nil {
return TimeSeries{}, nil
}
series, err := p.history.RangeQuery(ctx, `sum by (owner) (llmgw_gpu_assigned)`, "owner", win.Span, win.Width)
if err != nil || len(series) == 0 {
return TimeSeries{}, nil
}
var rows []gpuHoursRow
for i := range series {
if series[i].Name == "" {
series[i].Name = l.T("usage.no_owner")
}
row := gpuHoursRow{Owner: series[i].Name, Now: series[i].Last()}
// Each point is the cards held at that instant, standing for the
// step that follows it.
row.Hours = series[i].Sum() * win.Width.Hours()
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Hours > rows[j].Hours })
return l.LineChart(series, "", 6), rows
}
// 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(l *Lang, 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(l, 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(l *Lang, u store.UsageSeries) string {
if u.Name != "" {
return u.Name
}
if u.Grouping == store.GroupByUser {
return l.T("usage.no_owner_keys")
}
if u.ID != "" {
return u.ID
}
return l.T("usage.unknown")
}
// groupLabel names a grouping in l.
func groupLabel(l *Lang, g store.Grouping) string {
switch g {
case store.GroupByOrg, store.GroupByTeam, store.GroupByProject, store.GroupByUser, store.GroupByModel:
return l.T("usage.group." + string(g))
default:
return string(g)
}
}
// usageCharts is usageChartsIn in English.
func usageCharts(rows []store.UsageSeries) (req, tok, spend TimeSeries, table []usageRow) {
return usageChartsIn(english, rows)
}
// usageChartsIn draws usage over time three ways, with the table beneath
// them, the names and figures in l.
func usageChartsIn(l *Lang, rows []store.UsageSeries) (req, tok, spend TimeSeries, table []usageRow) {
req = l.LineChartTotals(seriesOf(l, rows, func(b store.Bucket) float64 {
return float64(b.Requests)
}), "", 4)
tok = l.LineChartTotals(seriesOf(l, rows, func(b store.Bucket) float64 {
return float64(b.Prompt + b.Completion)
}), "", 4)
spend = l.LineChartTotals(seriesOf(l, 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 {
table = append(table, usageRow{
ID: u.ID,
Name: displayName(l, u),
Requests: u.Requests,
Tokens: u.Tokens(),
Spend: l.Units(u.CostMicros),
Errors: u.Errors,
})
}
return req, tok, spend, table
}