CI / test (push) Successful in 5m35s
The errors other packages give a page (store, catalog, mirror) are shown in its language; a percentage is written as the language writes it (%93 in Turkish); the footer's time in its date form; and a stale form is refused with a sentence rather than "bad request". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
681 lines
23 KiB
Go
681 lines
23 KiB
Go
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")) {
|
|
badForm(w, r)
|
|
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")) {
|
|
badForm(w, r)
|
|
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))
|
|
}
|