package web import ( "context" "errors" "net/http" "strconv" "strings" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) // Priced says whether anything a request could name costs money. The // accountant implements it. type Priced interface{ AnythingPriced() bool } // QuotaAdmin is the store half of the quota page. type QuotaAdmin interface { AllOrgs(ctx context.Context) ([]store.Org, error) AllTeams(ctx context.Context) ([]store.TeamPath, error) AllProjects(ctx context.Context) ([]store.ProjectPath, error) AllLimits(ctx context.Context) (map[string]store.Limits, error) PutLimits(ctx context.Context, l store.Limits) error ClearLimits(ctx context.Context, scope store.Scope, id string) error UsageByScope(ctx context.Context, scope store.Scope, since time.Time) ([]store.ScopeUsage, error) // Tree is the same tree with ids all the way down, so a project is placed // under its team by id rather than by matching the start of its path — // which put projects under the wrong team when two organizations shared a // name. Tree(ctx context.Context) ([]store.TreeOrg, error) // People have limits of their own, across every key they hold. Users(ctx context.Context, state store.UserState) ([]store.User, error) UsageByUser(ctx context.Context, since time.Time) ([]store.UserUsage, error) } // scopeUsage is what spend per branch is read from. type scopeUsage interface { UsageByScope(ctx context.Context, scope store.Scope, since time.Time) ([]store.ScopeUsage, error) } // personRow is one person's own limits: across all their keys and projects. type personRow struct { ID, Email string RPM, TPM string Budget, Period string Models string HasAny bool // Shown is the same limits as a person reads them, in the page's // language. The fields above are what the form posts back, which is // written the C way whatever the page speaks. Shown quotaFields Spend string Percent int HasPct bool Editing bool } // quotaRow is one place a quota can be written, and what is written there. type quotaRow struct { Scope store.Scope ID string // Path is how a person recognises it: "Scania / platform / gateway". Path string // Indent is how deep in the tree it sits, so the table reads as one. Indent int // Crumb is the full path, for a chooser where indentation cannot carry it. Crumb string RPM string TPM string Budget string Period string Models string HasAny bool // Shown is the row's own limits in the page's language; the fields above // are the form's values. Shown quotaFields Spend string Percent int HasPct bool // Editing is true for the one row whose form is open. // // One at a time, chosen by a link rather than opened by script. A // disclosure on every row would put twenty collapsed forms in the markup of // a page whose job is to be read as a tree. Editing bool // Shadowed names an ancestor whose limit is stricter, which means the value // written here does nothing. // // Resolution takes the most restrictive along the chain, so a project given // a budget larger than its team's is not a bigger budget — it is a number // somebody typed that will never be reached. Without this the page would // show it as though it applied, and the first sign would be a refusal that // disagrees with what is on screen. Shadowed string // RPMFrom, TPMFrom and BudgetFrom name the ancestor a limit comes from when // this row sets none of its own, with the value in RPMUp, TPMUp and // BudgetUp. A project with "—" in every column read as unlimited when its // organization held it to sixty requests a minute. RPMUp, TPMUp, BudgetUp string RPMFrom, TPMFrom, BudgetFrom string // ModelsFrom names the nearest ancestor with a model list, for a row with // none of its own. ModelsFrom string // Member is the per-member limit written here, summarised; MemberRaw holds // its fields for the form, and MemberEditing opens that form. Member string MemberRaw quotaFields MemberEditing bool } // quotaFields is a limit as the form shows it. type quotaFields struct { RPM, TPM, Budget, Period, Models string HasAny bool } type adminQuotasData struct { Nav Nav Rows []quotaRow People []personRow // Personal summarises the defaults a person's own use falls under when // nobody set a limit for them. Personal string // BudgetsBind is false when no model has a price, which makes every budget // on this page a number that cannot move. Shown, because a limit that binds // on nothing and reads as "0% used" is worse than no limit at all. BudgetsBind bool // Editing is the "scope:id" whose form is open, so the template can keep // the link on every other row. Editing string // The capacity section: quotas on GPUs, cores and memory. HasCapacity bool Capacity []capacityRow Scopes []adminapi.ScopeName CapForm capacityForm Since string Message string Error string CSRF string Version string Now string } // quotaRefusal is a change to a limit the page would not make, said in whatever // language the page that answers it speaks. Its Error is English, which is // what the log line about it is written in. type quotaRefusal struct { key string args []any } func (e quotaRefusal) Error() string { return english.T(e.key, e.args...) } // quotaRefused is how a refusal is read on the page: its own text in the page's // language, else an error from elsewhere as l.Err has it. func (l *Lang) quotaRefused(err error) string { var r quotaRefusal if errors.As(err, &r) { return l.T(r.key, r.args...) } return l.Err(err) } // The refusals are built from a key in hand rather than written out as // literal lookups, so the catalogue test is told of them here. func init() { registerKeys("quotas.err.scope", "quotas.err.which", "quotas.err.rpm", "quotas.err.tpm", "quotas.err.budget", "quotas.err.period", "capacity.err.scope", "capacity.err.gpus", "capacity.err.cores", "capacity.err.memory", "capacity.err.limit_gpus", "capacity.err.limit_cores", "capacity.err.limit_memory") // The kind of a branch, and the period of a budget, are store values // that the page names in words. registerKeys("quotas.kind.org", "quotas.kind.team", "quotas.kind.project", "quotas.kind.user", "quotas.kind.org-member", "quotas.kind.team-member", "quotas.kind.project-member", "quotas.per.day", "quotas.per.month") } // RegisterAdminQuotas mounts quota administration under /admin/quotas. // // Reading and writing both, which is a change of position. Setting a quota was // kept to the shell on the grounds that raising a budget is a financial act and // a stolen admin session should not manage one. That argument holds for exactly // one row on this page: resolution takes the most restrictive value along the // chain, so raising a team or project limit cannot exceed what the organization // allows. Only the organization's own limit is bounded by nothing. // // So the trade is narrower than it looked, and against it stands a quota nobody // could see or change without a shell on the control plane — which in practice // means quotas nobody sets. Every change is logged with who made it and what it // was before. func (p *AccountPages) RegisterAdminQuotas(mux *http.ServeMux, s QuotaAdmin, priced Priced) { p.hasQuotas = true p.priced = priced p.quotaStore = s mux.HandleFunc("GET /admin/quotas", p.adminOnly( func(w http.ResponseWriter, r *http.Request, admin store.User) { p.renderPeople(w, r, admin, peopleView{Tab: tabLimits}) })) mux.HandleFunc("POST /admin/quotas", p.adminOnly( func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { http.Error(w, "bad request", http.StatusBadRequest) return } msg, err := p.applyQuota(r, s, admin) if err != nil { p.log.Warn("a quota change was refused", "admin", admin.Email, "scope", r.FormValue("scope"), "id", r.FormValue("id"), "err", err) p.renderQuotas(w, r, admin, "", langOf(r).quotaRefused(err)) return } p.renderQuotas(w, r, admin, msg, "") })) mux.HandleFunc("POST /admin/quotas/capacity", p.adminOnly( func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) if p.capacity == nil || !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { http.Error(w, "bad request", http.StatusBadRequest) return } msg, err := p.applyCapacity(r, admin) if err != nil { p.log.Warn("a capacity quota change was refused", "admin", admin.Email, "scope", r.FormValue("scope"), "err", err) p.renderQuotas(w, r, admin, "", langOf(r).quotaRefused(err)) return } p.log.Info("capacity quota changed", "admin", admin.Email, "scope", r.FormValue("scope")) p.renderQuotas(w, r, admin, msg, "") })) } // applyQuota writes one scope's limits, and says what it did in the // request's language. func (p *AccountPages) applyQuota(r *http.Request, s QuotaAdmin, admin store.User) (string, error) { l := langOf(r) // Carried by the form the row opened, so the branch a quota lands on is the // one that was clicked rather than one somebody named again in a box. scope, id := store.Scope(r.FormValue("scope")), r.FormValue("id") switch scope { case store.ScopeOrg, store.ScopeTeam, store.ScopeProject, store.ScopeUser, store.ScopeOrgMember, store.ScopeTeamMember, store.ScopeProjectMember: default: return "", quotaRefusal{key: "quotas.err.scope"} } if id == "" { return "", quotaRefusal{key: "quotas.err.which"} } // Before the write, so the audit line can say what it was. A record that a // budget changed without the number it changed from answers nothing later. before := "unknown" if all, err := s.AllLimits(r.Context()); err == nil { before = describeLimits(all[store.LimitOwner{Scope: scope, ID: id}.CounterKey()]) } if r.FormValue("clear") != "" { if err := s.ClearLimits(r.Context(), scope, id); err != nil { return "", err } p.log.Info("a quota was cleared", "admin", admin.Email, "scope", scope, "id", id, "was", before) return l.T("quotas.msg.cleared", l.quotaKind(scope), id), nil } lim := store.Limits{Scope: scope, ScopeID: id} var err error if lim.RPM, err = optInt(r.FormValue("rpm"), "quotas.err.rpm"); err != nil { return "", err } if lim.TPM, err = optInt(r.FormValue("tpm"), "quotas.err.tpm"); err != nil { return "", err } // Whole units in, micro-units stored: the operator set a budget in the unit // they think in, and the store keeps integers so repeated addition of a // cost cannot drift. if lim.Budget, err = optUnits(r.FormValue("budget")); err != nil { return "", err } if lim.Budget != nil { lim.BudgetPeriod = store.Period(r.FormValue("period")) if lim.BudgetPeriod != store.PeriodDay && lim.BudgetPeriod != store.PeriodMonth { return "", quotaRefusal{key: "quotas.err.period"} } } if m := strings.TrimSpace(r.FormValue("models")); m != "" { for _, name := range strings.Split(m, ",") { if name = strings.TrimSpace(name); name != "" { lim.AllowedModels = append(lim.AllowedModels, name) } } } if err := s.PutLimits(r.Context(), lim); err != nil { return "", err } p.log.Info("a quota was changed", "admin", admin.Email, "scope", scope, "id", id, "was", before, "now", describeLimits(lim)) return l.T("quotas.msg.saved", l.quotaKind(scope), id, l.limitsText(lim)), nil } // quotaKind names the kind of branch a scope is, in this language. func (l *Lang) quotaKind(scope store.Scope) string { switch scope { case store.ScopeOrg, store.ScopeTeam, store.ScopeProject, store.ScopeUser, store.ScopeOrgMember, store.ScopeTeamMember, store.ScopeProjectMember: return l.T("quotas.kind." + string(scope)) } return string(scope) } // budgetPeriod is a budget's period as a word of this language: "month", "ay". func (l *Lang) budgetPeriod(p store.Period) string { switch p { case store.PeriodDay, store.PeriodMonth: return l.T("quotas.per." + string(p)) } return string(p) } // renderQuotas answers a change to a limit: on the limits tab, or on the // people tab when it was made from a branch's own panel there. func (p *AccountPages) renderQuotas(w http.ResponseWriter, r *http.Request, admin store.User, msg, errMsg string, ) { tab := tabLimits if r.FormValue("tab") == tabPeople { tab = tabPeople } p.renderPeople(w, r, admin, peopleView{Tab: tab, Msg: msg, Err: errMsg}) } // quotaWindow is how far back spend against a budget is counted. const quotaWindow = 30 * 24 * time.Hour // buildQuotas is every limit, as the limits tab shows them. func (p *AccountPages) buildQuotas(r *http.Request, s QuotaAdmin, msg, errMsg string) adminQuotasData { const window = quotaWindow l := langOf(r) d := adminQuotasData{ Nav: navFrom(r, "quotas"), Since: l.T("quotas.since"), Message: msg, Error: errMsg, CSRF: csrfFor(r), Version: p.version, Now: time.Now().UTC().Format(time.RFC3339), } d.BudgetsBind = p.priced != nil && p.priced.AnythingPriced() d.Editing = r.URL.Query().Get("edit") p.capacitySection(r.Context(), l, &d, r.FormValue("cap")) limits, err := s.AllLimits(r.Context()) if err != nil { p.log.Error("could not read the limits", "err", err) d.Error = l.T("quotas.err.read_limits") return d } spend := p.spendByScope(r.Context(), s, time.Now().Add(-window)) tree, err := s.Tree(r.Context()) if err != nil { p.log.Error("could not read the tree", "err", err) d.Error = l.T("quotas.err.read_tree") } d.Rows = quotaRowsIn(l, tree, limits, spend) for i := range d.Rows { ms := store.MemberScope(d.Rows[i].Scope) if lim, ok := limits[store.LimitOwner{Scope: ms, ID: d.Rows[i].ID}.CounterKey()]; ok { d.Rows[i].MemberRaw = fieldsOf(lim) d.Rows[i].Member = l.limitsText(lim) } d.Rows[i].MemberEditing = d.Editing == string(ms)+":"+d.Rows[i].ID } d.People = p.personRowsIn(r.Context(), l, s, limits, time.Now().Add(-window), d.Editing) d.Personal = p.personalSummary(l) for i := range d.Rows { d.Rows[i].Editing = d.Editing == string(d.Rows[i].Scope)+":"+d.Rows[i].ID } return d } // spendByScope is what each group has used in the window, keyed like a limit. func (p *AccountPages) spendByScope(ctx context.Context, s scopeUsage, since time.Time, ) map[string]int64 { out := map[string]int64{} for _, sc := range []store.Scope{store.ScopeOrg, store.ScopeTeam, store.ScopeProject} { rows, err := s.UsageByScope(ctx, sc, since) if err != nil { // One level failing must not blank the other two: a page showing // two thirds of the answer is more use than an error. p.log.Warn("could not read usage for the quota page", "scope", sc, "err", err) continue } for _, u := range rows { out[store.LimitOwner{Scope: sc, ID: u.ID}.CounterKey()] = u.CostMicros } } return out } // quotaRows is quotaRowsIn for a page with no request to take a language // from: English. func quotaRows(tree []store.TreeOrg, limits map[string]store.Limits, spend map[string]int64) []quotaRow { return quotaRowsIn(english, tree, limits, spend) } // quotaRowsIn lays the tree out depth first, each row with what it sets and // what it is held to from above, said in l. func quotaRowsIn(l *Lang, tree []store.TreeOrg, limits map[string]store.Limits, spend map[string]int64) []quotaRow { var rows []quotaRow for _, o := range tree { org := quotaRow2(l, store.ScopeOrg, o.ID, o.Name, o.Name, 0, limits, spend, nil) rows = append(rows, org) for _, t := range o.Teams { crumb := o.Name + " / " + t.Name team := quotaRow2(l, store.ScopeTeam, t.ID, t.Name, crumb, 1, limits, spend, []ancestor{{o.Name, limits[key(store.ScopeOrg, o.ID)]}}) rows = append(rows, team) for _, pr := range t.Projects { rows = append(rows, quotaRow2(l, store.ScopeProject, pr.ID, pr.Name, crumb+" / "+pr.Name, 2, limits, spend, []ancestor{ {t.Name, limits[key(store.ScopeTeam, t.ID)]}, {o.Name, limits[key(store.ScopeOrg, o.ID)]}, })) } } } return rows } // ancestor is one level above a row, nearest first. type ancestor struct { name string l store.Limits } func key(scope store.Scope, id string) string { return store.LimitOwner{Scope: scope, ID: id}.CounterKey() } func quotaRow2(lang *Lang, scope store.Scope, id, name, crumb string, indent int, limits map[string]store.Limits, spend map[string]int64, up []ancestor, ) quotaRow { l := limits[key(scope, id)] row := quotaRow{ Scope: scope, ID: id, Path: name, Crumb: crumb, Indent: indent, RPM: dash(l.RPM), TPM: dash(l.TPM), Budget: dashUnits(l.Budget), Period: string(l.BudgetPeriod), Models: strings.Join(l.AllowedModels, ", "), HasAny: l.RPM != nil || l.TPM != nil || l.Budget != nil || l.AllowedModels != nil, Shown: lang.shownFields(l), Spend: lang.Units(spend[key(scope, id)]), } if l.Budget != nil && *l.Budget > 0 { row.Percent = int(spend[key(scope, id)] * 100 / *l.Budget) row.HasPct = true } // The strictest value above, and who set it. Resolution takes the most // restrictive along the chain, so this is what applies when the row sets // nothing, and what makes a larger value set here unreachable. var rpm, tpm *int var budget *int64 var period store.Period for _, a := range up { if a.l.RPM != nil && (rpm == nil || *a.l.RPM < *rpm) { rpm, row.RPMFrom = a.l.RPM, a.name } if a.l.TPM != nil && (tpm == nil || *a.l.TPM < *tpm) { tpm, row.TPMFrom = a.l.TPM, a.name } if a.l.Budget != nil && (budget == nil || *a.l.Budget < *budget) { budget, period, row.BudgetFrom = a.l.Budget, a.l.BudgetPeriod, a.name } if row.ModelsFrom == "" && a.l.AllowedModels != nil { row.ModelsFrom = a.name } } // What is held from above is only ever shown, never posted back, so it // is written in the page's language. if rpm != nil { row.RPMUp = lang.Int(int64(*rpm)) } if tpm != nil { row.TPMUp = lang.Int(int64(*tpm)) } if budget != nil { row.BudgetUp = lang.Units(*budget) if period != "" { row.BudgetUp += "/" + lang.budgetPeriod(period) } } var capped []string if l.RPM != nil && rpm != nil && *rpm < *l.RPM { capped = append(capped, lang.T("quotas.capped.rpm", row.RPMUp)) } if l.TPM != nil && tpm != nil && *tpm < *l.TPM { capped = append(capped, lang.T("quotas.capped.tpm", row.TPMUp)) } if l.Budget != nil && budget != nil && *budget < *l.Budget { capped = append(capped, lang.T("quotas.capped.budget", lang.Units(*budget))) } if len(capped) > 0 { from := row.RPMFrom if from == "" { from = row.TPMFrom } if from == "" { from = row.BudgetFrom } row.Shadowed = lang.T("quotas.shadowed.above", strings.Join(capped, lang.T("quotas.and"))) if from != "" && len(capped) == 1 { row.Shadowed = lang.T("quotas.shadowed.by", from, capped[0]) } } return row } // optInt reads a whole number from a form, refused with the text under // errKey when it is not one. func optInt(v, errKey string) (*int, error) { if strings.TrimSpace(v) == "" { return nil, nil } n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil || n < 0 { return nil, quotaRefusal{key: errKey} } return &n, nil } // optUnits reads a budget written in whole units and stores micro-units. func optUnits(v string) (*int64, error) { if strings.TrimSpace(v) == "" { return nil, nil } f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) if err != nil || f < 0 { return nil, quotaRefusal{key: "quotas.err.budget"} } m := int64(f * 1e6) return &m, nil } // describeLimits is a limit as the audit log records it: English and C // numbers, whoever made the change. func describeLimits(l store.Limits) string { return english.limitsText(l) } // limitsText is a limit summarised in this language: "rpm 60, budget // 12.50/month". func (lang *Lang) limitsText(l store.Limits) string { var parts []string if l.RPM != nil { parts = append(parts, lang.T("quotas.desc.rpm", lang.Int(int64(*l.RPM)))) } if l.TPM != nil { parts = append(parts, lang.T("quotas.desc.tpm", lang.Int(int64(*l.TPM)))) } if l.Budget != nil { parts = append(parts, lang.T("quotas.desc.budget", lang.Units(*l.Budget)+"/"+lang.budgetPeriod(l.BudgetPeriod))) } if l.AllowedModels != nil { parts = append(parts, lang.T("quotas.desc.models", strings.Join(l.AllowedModels, "|"))) } if len(parts) == 0 { return lang.T("quotas.desc.none") } return strings.Join(parts, ", ") } func dash(v *int) string { if v == nil { return "" } return strconv.Itoa(*v) } func dashUnits(v *int64) string { if v == nil { return "" } return units(*v) } // fieldsOf is a limit as the form's values: C numbers, the store's period. func fieldsOf(l store.Limits) quotaFields { return quotaFields{ RPM: dash(l.RPM), TPM: dash(l.TPM), Budget: dashUnits(l.Budget), Period: string(l.BudgetPeriod), Models: strings.Join(l.AllowedModels, ", "), HasAny: l.RPM != nil || l.TPM != nil || l.Budget != nil || l.AllowedModels != nil, } } // shownFields is a limit as a person reads it in this language. func (lang *Lang) shownFields(l store.Limits) quotaFields { f := fieldsOf(l) if l.RPM != nil { f.RPM = lang.Int(int64(*l.RPM)) } if l.TPM != nil { f.TPM = lang.Int(int64(*l.TPM)) } if l.Budget != nil { f.Budget = lang.Units(*l.Budget) } if l.BudgetPeriod != "" { f.Period = lang.budgetPeriod(l.BudgetPeriod) } return f } // personRows is personRowsIn in English. func (p *AccountPages) personRows(ctx context.Context, s QuotaAdmin, limits map[string]store.Limits, since time.Time, editing string, ) []personRow { return p.personRowsIn(ctx, english, s, limits, since, editing) } // personRowsIn lists every approved person with the limits written for them // and what they spent, wherever they spent it. func (p *AccountPages) personRowsIn(ctx context.Context, lang *Lang, s QuotaAdmin, limits map[string]store.Limits, since time.Time, editing string, ) []personRow { users, err := s.Users(ctx, "") if err != nil { p.log.Warn("could not list people for the quota page", "err", err) return nil } spent := map[string]int64{} if usage, err := s.UsageByUser(ctx, since); err == nil { for _, u := range usage { spent[u.UserID] = u.CostMicros } } var out []personRow for _, u := range users { if u.State == store.UserPending { continue } l := limits[store.LimitOwner{Scope: store.ScopeUser, ID: u.ID}.CounterKey()] f := fieldsOf(l) row := personRow{ID: u.ID, Email: u.Email, RPM: f.RPM, TPM: f.TPM, Budget: f.Budget, Period: f.Period, Models: f.Models, HasAny: f.HasAny, Shown: lang.shownFields(l), Spend: lang.Units(spent[u.ID]), Editing: editing == "user:"+u.ID} if l.Budget != nil && *l.Budget > 0 { row.Percent, row.HasPct = int(spent[u.ID]*100 / *l.Budget), true } out = append(out, row) } return out } // SetPersonalDefaults tells the quota page what a person's own use falls under // when nobody set a limit for them. func (p *AccountPages) SetPersonalDefaults(l *store.Limits) { p.personalDefaults = l } func (p *AccountPages) personalSummary(l *Lang) string { if p.personalOff { return l.T("quotas.personal.off") } if p.personalDefaults == nil { return l.T("quotas.personal.none") } return l.T("quotas.personal.held", l.limitsText(*p.personalDefaults)) }