Files
llmgw/internal/web/admin_people.go
T
Ilker ManapandClaude Opus 5.5 9f479b9a70 Show each team's first-token and latency percentiles, errors and refusals
Percentiles are read from the histograms kept with the usage sums and shown
per group on the usage tab and in the people page's panels. Requests refused
by a limit are recorded as 429s against their owner, free of charge, so a team
hitting its limits shows as such. Usage can be grouped by model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:50:16 +02:00

269 lines
7.6 KiB
Go

package web
import (
"context"
"net/http"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// The people page's tabs. One page, because people, the limits they are held
// to and what they used are three views of one tree: managing a team meant its
// members on one page, its quota on another and its spend on a third, each
// linking to the others. Each tab keeps the address it had as a page of its
// own, so links and bookmarks to /admin/quotas and /admin/usage still land.
const (
tabPeople = "people"
tabLimits = "limits"
tabUsage = "usage"
)
// peopleView is what a request asks the people page for.
type peopleView struct {
Tab string
Msg, Err string
// Open is the project shown expanded in the tree.
Open string
}
type peopleData struct {
Nav Nav
Tab string
// The tabs this server has, each built only when it is the one shown.
HasPeople, HasLimits, HasUsage bool
Users *adminUsersData
Quotas *adminQuotasData
Usage *adminUsageData
// Detail is the branch or person chosen in the tree, nil when none is.
Detail *peopleDetail
// Sel is what was chosen, "team:<id>" or "user:<id>", for links to keep.
Sel string
Pending int
Version string
Now string
}
// peopleDetail is one organization, team, project or person, with everything
// the three tabs say about it in one place.
type peopleDetail struct {
Kind store.Scope
ID string
// Name is what it is called, Path where it sits.
Name, Path string
// Limits is its own quota row, with what it is held to from above; nil
// for a person, or where limits are not mounted.
Limits *quotaRow
// Person is a person's own limit.
Person *personRow
// Memberships are where a person is.
Memberships []store.Membership
// Capacity is its capacity quota, nil when it has none.
Capacity *capacityRow
HasCapacity bool
BudgetsBind bool
// Its use over the last week, and the totals beside it.
HasUsage bool
Requests, Tokens, Spend TimeSeries
Totals usageRow
Priced bool
// Stats is how its requests ended and how long they took, over the
// same week; HasStats is false where the store does not say.
Stats store.GroupStats
HasStats bool
UsageWindow, QuotaWindow string
CSRF string
}
// renderPeople draws the people page on the tab asked for.
func (p *AccountPages) renderPeople(w http.ResponseWriter, r *http.Request, admin store.User, v peopleView) {
d := peopleData{
Nav: navFrom(r, "people"), Tab: v.Tab,
HasPeople: p.peopleStore != nil, HasLimits: p.quotaStore != nil, HasUsage: p.usageStore != nil,
Sel: r.FormValue("sel"),
Version: p.version, Now: time.Now().UTC().Format(time.RFC3339),
}
switch {
case d.Tab == tabPeople && d.HasPeople:
u := p.buildUsers(r, p.peopleStore, admin, v.Msg, v.Err, v.Open)
d.Users, d.Pending = &u, len(u.Pending)
if d.Sel != "" {
d.Detail = p.detail(r, d.Sel, &u)
}
case d.Tab == tabLimits && d.HasLimits:
q := p.buildQuotas(r, p.quotaStore, v.Msg, v.Err)
d.Quotas = &q
case d.Tab == tabUsage && d.HasUsage:
u := p.buildUsage(r, p.usageStore, p.usagePriced)
d.Usage = &u
default:
// The tab asked for is not mounted here: the first one that is.
switch {
case d.HasPeople:
v.Tab = tabPeople
case d.HasLimits:
v.Tab = tabLimits
case d.HasUsage:
v.Tab = tabUsage
default:
http.NotFound(w, r)
return
}
p.renderPeople(w, r, admin, v)
return
}
render(w, "admin_people", d)
}
// usageGrouping is the grouping a branch's or a person's use is read under.
func usageGrouping(kind store.Scope) (store.Grouping, bool) {
switch kind {
case store.ScopeOrg:
return store.GroupByOrg, true
case store.ScopeTeam:
return store.GroupByTeam, true
case store.ScopeProject:
return store.GroupByProject, true
case store.ScopeUser:
return store.GroupByUser, true
}
return "", false
}
// detail gathers what the page knows about one branch or person.
func (p *AccountPages) detail(r *http.Request, sel string, u *adminUsersData) *peopleDetail {
kind, id, ok := strings.Cut(sel, ":")
if !ok || id == "" {
return nil
}
ctx := r.Context()
d := &peopleDetail{Kind: store.Scope(kind), ID: id, CSRF: csrfFor(r),
UsageWindow: "last 7 days", QuotaWindow: "30 days"}
if !p.nameDetail(d, u) {
return nil
}
if p.quotaStore != nil {
d.BudgetsBind = p.priced != nil && p.priced.AnythingPriced()
p.limitsDetail(ctx, d)
}
if p.capacity != nil && d.Kind != store.ScopeUser {
d.HasCapacity = true
if views, err := p.capacity.QuotaViews(ctx); err == nil {
for _, v := range views {
if v.Scope == sel {
c := capacityRow{QuotaView: v,
GPUs: capCell(v.Holds.GPUs, v.Guarantee.GPUs, v.Limit.GPUs),
Cores: capCell(v.Holds.CPUCores, v.Guarantee.CPUCores, v.Limit.CPUCores),
Memory: capCell(v.Holds.MemoryGB, v.Guarantee.MemoryGB, v.Limit.MemoryGB)}
d.Capacity = &c
}
}
}
}
if p.usageStore != nil {
p.usageDetail(ctx, d)
}
return d
}
// nameDetail finds what was chosen in the tree or among the accounts, and
// says whether it is there at all.
func (p *AccountPages) nameDetail(d *peopleDetail, u *adminUsersData) bool {
if d.Kind == store.ScopeUser {
for _, a := range u.Accounts {
if a.ID == d.ID {
d.Name, d.Path, d.Memberships = a.Email, a.Name, a.Memberships
return true
}
}
return false
}
for _, o := range u.Tree {
if d.Kind == store.ScopeOrg && o.ID == d.ID {
d.Name, d.Path = o.Name, o.Name
return true
}
for _, t := range o.Teams {
if d.Kind == store.ScopeTeam && t.ID == d.ID {
d.Name, d.Path = t.Name, o.Name+" / "+t.Name
return true
}
for _, pr := range t.Projects {
if d.Kind == store.ScopeProject && pr.ID == d.ID {
d.Name, d.Path = pr.Name, o.Name+" / "+t.Name+" / "+pr.Name
return true
}
}
}
}
return false
}
// limitsDetail is the branch's own quota row, or the person's own limit.
func (p *AccountPages) limitsDetail(ctx context.Context, d *peopleDetail) {
s := p.quotaStore
limits, err := s.AllLimits(ctx)
if err != nil {
p.log.Warn("could not read the limits for a detail panel", "err", err)
return
}
since := time.Now().Add(-quotaWindow)
if d.Kind == store.ScopeUser {
for _, row := range p.personRows(ctx, s, limits, since, "") {
if row.ID == d.ID {
row := row
d.Person = &row
}
}
return
}
tree, err := s.Tree(ctx)
if err != nil {
return
}
for _, row := range quotaRows(tree, limits, p.spendByScope(ctx, s, since)) {
if row.Scope == d.Kind && row.ID == d.ID {
ms := store.MemberScope(row.Scope)
if l, ok := limits[store.LimitOwner{Scope: ms, ID: row.ID}.CounterKey()]; ok {
row.MemberRaw, row.Member = fieldsOf(l), describeLimits(l)
}
d.Limits = &row
return
}
}
}
// usageDetail is the branch's or person's use over the last week, drawn.
func (p *AccountPages) usageDetail(ctx context.Context, d *peopleDetail) {
g, ok := usageGrouping(d.Kind)
if !ok {
return
}
win := windowFor("7d")
rows, err := p.usageStore.UsageBucketsBy(ctx, g, time.Now().Add(-win.Span), win.Width)
if err != nil {
p.log.Warn("could not read usage for a detail panel", "sel", string(d.Kind)+":"+d.ID, "err", err)
return
}
d.HasUsage = true
d.Priced = p.usagePriced != nil && p.usagePriced.AnythingPriced()
if sr, ok := p.usageStore.(StatsReader); ok {
if stats, err := sr.StatsBy(ctx, g, time.Now().Add(-win.Span)); err == nil {
d.Stats, d.HasStats = stats[d.ID], true
}
}
for _, s := range rows {
if s.ID == d.ID {
var table []usageRow
d.Requests, d.Tokens, d.Spend, table = usageCharts([]store.UsageSeries{s})
d.Totals = table[0]
return
}
}
}