Files
llmgw/internal/web/admin_people.go
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

277 lines
8.0 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
}
// The panel names what was chosen by its kind, looked up by the value it is
// stored as. Its own words rather than the limits tab's, because a panel
// about a user says "person".
func init() {
registerKeys("people.kind.org", "people.kind.team", "people.kind.project", "people.kind.user")
}
// 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, r, "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()
l := langOf(r)
d := &peopleDetail{Kind: store.Scope(kind), ID: id, CSRF: csrfFor(r),
UsageWindow: l.T("usage.window.7d"), QuotaWindow: l.T("quotas.since")}
if !p.nameDetail(d, u) {
return nil
}
if p.quotaStore != nil {
d.BudgetsBind = p.priced != nil && p.priced.AnythingPriced()
p.limitsDetail(ctx, l, 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: l.capCell(v.Holds.GPUs, v.Guarantee.GPUs, v.Limit.GPUs),
Cores: l.capCell(v.Holds.CPUCores, v.Guarantee.CPUCores, v.Limit.CPUCores),
Memory: l.capCell(v.Holds.MemoryGB, v.Guarantee.MemoryGB, v.Limit.MemoryGB)}
d.Capacity = &c
}
}
}
}
if p.usageStore != nil {
p.usageDetail(ctx, l, 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, l *Lang, 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.personRowsIn(ctx, l, 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 quotaRowsIn(l, tree, limits, p.spendByScope(ctx, s, since)) {
if row.Scope == d.Kind && row.ID == d.ID {
ms := store.MemberScope(row.Scope)
if m, ok := limits[store.LimitOwner{Scope: ms, ID: row.ID}.CounterKey()]; ok {
row.MemberRaw, row.Member = fieldsOf(m), l.limitsText(m)
}
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, l *Lang, 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 = usageChartsIn(l, []store.UsageSeries{s})
d.Totals = table[0]
return
}
}
}