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>
137 lines
3.9 KiB
Go
137 lines
3.9 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"html/template"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/accounting"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// SelfUsage is what a person's own page reads of their use. Optional: a
|
|
// store without it leaves the page showing limits alone.
|
|
type SelfUsage interface {
|
|
UsageOfUser(ctx context.Context, userID string, since time.Time, width time.Duration) ([]store.PlaceUsage, []store.Bucket, error)
|
|
SpendOfPool(ctx context.Context, l store.Limits, since time.Time) (int64, error)
|
|
}
|
|
|
|
// selfWindow is how far back a person's own page looks.
|
|
const selfWindow = 30 * 24 * time.Hour
|
|
|
|
// ownUse is the person's last thirty days, for the top of their page.
|
|
type ownUse struct {
|
|
Requests, Tokens int64
|
|
Spend string
|
|
Chart template.HTML
|
|
}
|
|
|
|
// budgetBar is one budget that applies, and how much of it is gone.
|
|
type budgetBar struct {
|
|
// Label says whose pool it is: the person's own, or one they share.
|
|
Label string
|
|
Used, Budget string
|
|
Period string
|
|
Percent int
|
|
// Near is past four-fifths, when it is worth colouring.
|
|
Near, Over bool
|
|
}
|
|
|
|
// placeUse is what a person spent in one place over the window.
|
|
type placeUse struct {
|
|
Requests, Tokens int64
|
|
Spend string
|
|
}
|
|
|
|
func (p *AccountPages) fillOwnUse(ctx context.Context, l *Lang, user store.User, d *profileData) {
|
|
su, ok := p.keys.(SelfUsage)
|
|
if !ok {
|
|
return
|
|
}
|
|
places, buckets, err := su.UsageOfUser(ctx, user.ID, time.Now().Add(-selfWindow), 24*time.Hour)
|
|
if err != nil {
|
|
p.log.Warn("could not read a person's own usage", "err", err)
|
|
return
|
|
}
|
|
u := &ownUse{}
|
|
var cost int64
|
|
byPlace := map[string]placeUse{}
|
|
for _, pl := range places {
|
|
u.Requests += pl.Requests
|
|
u.Tokens += pl.Tokens
|
|
cost += pl.CostMicros
|
|
byPlace[pl.ProjectID] = placeUse{pl.Requests, pl.Tokens, l.Units(pl.CostMicros)}
|
|
}
|
|
u.Spend = l.Units(cost)
|
|
s := Series{Colour: "--accent"}
|
|
for _, b := range buckets {
|
|
s.Values = append(s.Values, float64(b.Prompt+b.Completion))
|
|
s.Labels = append(s.Labels, l.Format(b.Start.UTC(), "Jan 2"))
|
|
}
|
|
if u.Requests > 0 {
|
|
u.Chart = l.BarChart(s, l.T("selfuse.chart_unit"))
|
|
}
|
|
d.Own = u
|
|
|
|
for i := range d.LimitsBy {
|
|
c := &d.LimitsBy[i]
|
|
if pu, ok := byPlace[c.project]; ok {
|
|
c.Use = &pu
|
|
}
|
|
for _, lim := range c.chain {
|
|
if lim.Budget == nil || lim.Scope == store.ScopeKey {
|
|
continue
|
|
}
|
|
start := accounting.PeriodStart(lim.BudgetPeriod, time.Now())
|
|
spent, err := su.SpendOfPool(ctx, lim, start)
|
|
if err != nil {
|
|
p.log.Warn("could not read a pool's spend", "scope", lim.Scope, "err", err)
|
|
continue
|
|
}
|
|
bar := budgetBar{Label: poolLabel(l, lim.Scope), Used: l.Units(spent), Budget: l.Units(*lim.Budget),
|
|
Period: periodWord(l, lim.BudgetPeriod)}
|
|
if *lim.Budget > 0 {
|
|
bar.Percent = int(min(spent*100 / *lim.Budget, 100))
|
|
bar.Near = spent*5 >= *lim.Budget*4
|
|
bar.Over = spent >= *lim.Budget
|
|
}
|
|
c.Budgets = append(c.Budgets, bar)
|
|
}
|
|
}
|
|
}
|
|
|
|
// poolLabel says whose budget a limit is, which is the thing a person needs
|
|
// to read a bar right: their own is theirs to spend, a project's is shared
|
|
// with everyone in it.
|
|
func poolLabel(l *Lang, s store.Scope) string {
|
|
switch s {
|
|
case store.ScopeUser:
|
|
return l.T("selfuse.pool.user")
|
|
case store.ScopeProjectMember:
|
|
return l.T("selfuse.pool.project_member")
|
|
case store.ScopeTeamMember:
|
|
return l.T("selfuse.pool.team_member")
|
|
case store.ScopeOrgMember:
|
|
return l.T("selfuse.pool.org_member")
|
|
case store.ScopeProject:
|
|
return l.T("selfuse.pool.project")
|
|
case store.ScopeTeam:
|
|
return l.T("selfuse.pool.team")
|
|
case store.ScopeOrg:
|
|
return l.T("selfuse.pool.org")
|
|
}
|
|
return string(s)
|
|
}
|
|
|
|
func periodWord(l *Lang, p store.Period) string {
|
|
switch p {
|
|
case store.PeriodDay:
|
|
return l.T("selfuse.period.day")
|
|
case store.PeriodMonth:
|
|
return l.T("selfuse.period.month")
|
|
case store.PeriodNone:
|
|
return l.T("selfuse.period.none")
|
|
}
|
|
return l.T("selfuse.period.other", string(p))
|
|
}
|