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:" or "user:", 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 } } }