C1. A membership of an organization may be admin as well as member and manager. An admin sees /org as a manager does and changes the organization from it: its teams and projects, who of the approved accounts is in them, its members' roles, and the keys spent in its projects. Every id a form names is checked against the organization; limits, accounts, the organization's name and the cluster stay with the cluster's staff, and an admin cannot take their own role away. A group from OIDC, SCIM or a directory no longer rewrites a membership it names as a plain member at every sign-in, which took a manager's role away (EnsureMembership). Pages draw a form only for someone who may send it: Nav.May asks the staff rule staffMay keeps, and a page whose forms a person may not send gets no form token. A viewer sees the people, limits and mirror pages without forms; an operator the mirror's. Forms are compact: labels close to their fields, fields as wide as what goes in them, smaller buttons, and the short forms on one line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
277 lines
8.0 KiB
Go
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: csrfIfMay(r, "/admin/users"),
|
|
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
|
|
}
|
|
}
|
|
}
|