A team's members, quota and spend were on three pages that linked to each other. They are three tabs of one page now, each at its old address, and choosing anything in the tree opens its limits, capacity and use beside it, editable in place. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
258 lines
7.5 KiB
Go
258 lines
7.5 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)
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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
|
|
|
|
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"))
|
|
|
|
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"
|
|
return d
|
|
}
|
|
|
|
d.Requests, d.Tokens, d.Spend, d.Rows = usageCharts(rows)
|
|
d.GPUHeld, d.GPUHours = p.gpuHours(r.Context(), 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, 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 = "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 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(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)
|
|
}
|
|
}
|
|
|
|
// usageCharts draws usage over time three ways, with the table beneath them.
|
|
func usageCharts(rows []store.UsageSeries) (req, tok, spend TimeSeries, table []usageRow) {
|
|
req = LineChartTotals(seriesOf(rows, func(b store.Bucket) float64 {
|
|
return float64(b.Requests)
|
|
}), "", 4)
|
|
tok = LineChartTotals(seriesOf(rows, func(b store.Bucket) float64 {
|
|
return float64(b.Prompt + b.Completion)
|
|
}), "", 4)
|
|
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 {
|
|
table = append(table, usageRow{
|
|
Name: displayName(u),
|
|
Requests: u.Requests,
|
|
Tokens: u.Tokens(),
|
|
Spend: unitsOf(u.CostMicros),
|
|
Errors: u.Errors,
|
|
})
|
|
}
|
|
return req, tok, spend, table
|
|
}
|