diff --git a/docs/install.md b/docs/install.md index c5923ce..ebc79eb 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1337,6 +1337,34 @@ thing to get right is the health check: point it at `/ready`, not `/health`. so a load balancer using it would send half the traffic to something that refuses every request. +## The portal's language + +The web portal — the landing page, sign-up and sign-in, the account and chat +pages, and the administrators' pages — speaks English and Turkish. Each person +gets, in this order: + +1. the language they chose with the switcher at the foot of every page, which + is remembered in a cookie (`llmgw_lang`) for a year; +2. otherwise the first language their browser asks for that the portal speaks; +3. otherwise the server's own default: + +```yaml +# /etc/llmgw/server.yaml +landing: + language: tr # en or tr; empty is English +``` + +A code the portal does not speak stops the server starting, rather than +quietly serving English. Figures and dates follow the page's language: a +Turkish page writes 1.234,5 and 04.10.2026. Messages that come from the +control plane itself — a node's warnings, an engine's errors — are shown as +they were written, in English. The API, the CLI and the console are English. + +Adding a language is adding a directory: `internal/web/locales//` with +the same files and keys as `locales/en/`, and its `_meta.*` entries saying how +it writes numbers and dates. The tests refuse a catalogue that lacks a key a +page uses. + ## Single sign-on People can sign in through your identity provider — Keycloak, Entra ID, diff --git a/docs/reference.md b/docs/reference.md index 67325c4..403c141 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -89,6 +89,7 @@ rather than repeated. | `latency_budget` | `time.Duration` | [JudgePolicy](#judgepolicy) | | `contact` | `string` | [Landing](#landing) | | `notice` | `string` | [Landing](#landing) | +| `language` | `string` | [Landing](#landing) | | `bin` | `string` | [LlamaCppConfig](#llamacppconfig) | | `parallel` | `int` | [LlamaCppConfig](#llamacppconfig) | | `threads` | `int` | [LlamaCppConfig](#llamacppconfig) | @@ -846,6 +847,14 @@ channel name. Rendered as a link when it is one, as text otherwise. Notice is an optional line of your own, for whatever the reader needs to know that this page cannot guess. +### `language` + +`string` + +Language is what the web portal speaks to someone who has not chosen +a language with the switcher at the foot of every page and whose +browser asks for none it speaks: "en" or "tr". Empty is English. + ## LlamaCppConfig ### `bin` diff --git a/internal/cli/server.go b/internal/cli/server.go index d155582..693e5e7 100644 --- a/internal/cli/server.go +++ b/internal/cli/server.go @@ -377,6 +377,7 @@ func newServerCmd() *cobra.Command { api, usage, version.Version, 5*time.Second, true, web.NavExtras{History: history, Window: cfg.Monitoring.Window}, log)) adminMux.Handle("GET /_static/", web.Static()) + adminMux.HandleFunc("GET /_lang/{code}", web.SwitchLanguage) adminMux.HandleFunc("GET /nodes/{id}/logs", web.NodeLogsPage(api, true, version.Version)) adminMux.HandleFunc("GET /nodes/{id}", web.NodePage(api, history, cfg.Monitoring.Window, true, version.Version, log)) adminMux.HandleFunc("GET /nvlink", web.NVLinkPage(api, true, version.Version)) diff --git a/internal/cli/serverinit.go b/internal/cli/serverinit.go index 03e6f5b..56257cc 100644 --- a/internal/cli/serverinit.go +++ b/internal/cli/serverinit.go @@ -588,6 +588,9 @@ auth: landing: contact: "%s" + # What the web portal speaks to someone whose browser asks for neither + # language it has: en or tr. Each person can switch at the foot of a page. + # language: tr %s # The engines every node installs for itself. Omitted, each gets the llama.cpp diff --git a/internal/config/config.go b/internal/config/config.go index 23c0f55..ce78b2d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -261,6 +261,10 @@ type Landing struct { // Notice is an optional line of your own, for whatever the reader needs to // know that this page cannot guess. Notice string `koanf:"notice"` + // Language is what the web portal speaks to someone who has not chosen + // a language with the switcher at the foot of every page and whose + // browser asks for none it speaks: "en" or "tr". Empty is English. + Language string `koanf:"language"` } // Threads configures conversation and memory storage for a gateway without diff --git a/internal/docgen/reference_gen.go b/internal/docgen/reference_gen.go index 4503a72..69710c5 100644 --- a/internal/docgen/reference_gen.go +++ b/internal/docgen/reference_gen.go @@ -103,6 +103,7 @@ var Reference = []Block{ {Package: "config", Name: "Landing", Title: "Landing", Doc: "", Fields: []Field{ {Key: "contact", Type: "string", Doc: "Contact is where someone without a key should go — an address, a URL, a\nchannel name. Rendered as a link when it is one, as text otherwise.", Nested: ""}, {Key: "notice", Type: "string", Doc: "Notice is an optional line of your own, for whatever the reader needs to\nknow that this page cannot guess.", Nested: ""}, + {Key: "language", Type: "string", Doc: "Language is what the web portal speaks to someone who has not chosen\na language with the switcher at the foot of every page and whose\nbrowser asks for none it speaks: \"en\" or \"tr\". Empty is English.", Nested: ""}, }}, {Package: "engines", Name: "LlamaCppConfig", Title: "LlamaCppConfig", Doc: "", Fields: []Field{ {Key: "bin", Type: "string", Doc: "Bin is the llama-server binary. It is the server, not the CLI: the\ngateway talks OpenAI over HTTP and needs the one that serves it.\nEmpty means the agent installs llama.cpp itself, at the version the\ncontrol plane asks for. Setting it opts out of that.", Nested: ""}, diff --git a/internal/router/handlers.go b/internal/router/handlers.go index 5f97865..d8e3bc2 100644 --- a/internal/router/handlers.go +++ b/internal/router/handlers.go @@ -170,6 +170,9 @@ func (r *Router) Handler() http.Handler { // installation, so it needs no key either. mux.HandleFunc("GET /about", web.About(version.Version)) mux.Handle("GET /_static/", web.Static()) + // The language switcher at the foot of every page, which has to work + // before anyone has signed in. + mux.HandleFunc("GET /_lang/{code}", web.SwitchLanguage) mux.HandleFunc("GET /me", r.handleMe) // Somebody has to be able to say "forget that", and a retention window is // no answer to a message sent by mistake. Authenticated like any other @@ -291,7 +294,7 @@ func (r *Router) Handler() http.Handler { // route must not be able to add an unauthenticated one by omission. Health // stays open because a load balancer has no key. return auth.Middleware(r.auth, r.log, - "/health", "/ready", "/leader", "/", "/about", "/_static/", "/claim/", + "/health", "/ready", "/leader", "/", "/about", "/_static/", "/_lang/", "/claim/", "/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/", "/admin", "/admin/", "/chat", "/chat/", "/org", "/org/", "/setup/")(mux) } diff --git a/internal/router/language_test.go b/internal/router/language_test.go new file mode 100644 index 0000000..c72c4fd --- /dev/null +++ b/internal/router/language_test.go @@ -0,0 +1,71 @@ +package router + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/web" +) + +// The switcher is at the foot of the sign-in page, so it has to work for +// somebody with neither a key nor a session — and the choice has to stick. +func TestTheLanguageSwitchNeedsNoKeyAndSticks(t *testing.T) { + rt, _ := chatRouter(t, "http://127.0.0.1:1") + h := rt.Handler() + + r := httptest.NewRequest(http.MethodGet, "http://gw.example/_lang/tr", nil) + r.Header.Set("Referer", "http://gw.example/login") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/login" { + t.Fatalf("%d to %q: %s", w.Code, w.Header().Get("Location"), w.Body.String()) + } + cookies := w.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("no cookie remembers the choice") + } + + r = httptest.NewRequest(http.MethodGet, "http://gw.example/login", nil) + for _, c := range cookies { + r.AddCookie(c) + } + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if !strings.Contains(w.Body.String(), ``) { + t.Errorf("the sign-in page after choosing Turkish: %d %.300s", w.Code, w.Body.String()) + } +} + +func TestAServerSetToTurkishGreetsVisitorsInTurkish(t *testing.T) { + t.Cleanup(func() { _ = web.SetDefaultLanguage("") }) + cfg := baseConfig() + cfg.Landing.Language = "tr" + rt := newTestRouter(t, cfg) + + w := httptest.NewRecorder() + rt.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + if !strings.Contains(w.Body.String(), ``) { + t.Errorf("the landing page with no language asked for: %.300s", w.Body.String()) + } + + // A browser that asks for English still gets it. + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Accept-Language", "en-GB,en;q=0.9") + w = httptest.NewRecorder() + rt.Handler().ServeHTTP(w, r) + if !strings.Contains(w.Body.String(), ``) { + t.Errorf("the landing page asked for in English: %.300s", w.Body.String()) + } +} + +func TestALanguageThePortalDoesNotSpeakStopsTheServer(t *testing.T) { + t.Cleanup(func() { _ = web.SetDefaultLanguage("") }) + cfg := baseConfig() + cfg.Landing.Language = "klingon" + if _, err := New(cfg, slog.New(slog.DiscardHandler)); err == nil || !strings.Contains(err.Error(), "landing.language") { + t.Errorf("got %v, want an error naming landing.language", err) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index e004443..d2c658e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -245,6 +245,13 @@ func New(cfg config.Server, log *slog.Logger) (*Router, error) { return nil, fmt.Errorf("unknown default_profile %q", cfg.Router.DefaultProfile) } + // The portal's language for whoever neither chose one nor asked for one + // it speaks. Checked here so a mistyped code stops the server starting + // rather than quietly serving English. + if err := web.SetDefaultLanguage(cfg.Landing.Language); err != nil { + return nil, fmt.Errorf("landing.language: %w", err) + } + r := &Router{ batchWake: make(chan struct{}, 1), cfg: cfg.Router, diff --git a/internal/web/about.go b/internal/web/about.go index 71ede4d..aaaa759 100644 --- a/internal/web/about.go +++ b/internal/web/about.go @@ -74,6 +74,6 @@ func RenderAbout(w io.Writer, d AboutData) error { func About(version string) http.HandlerFunc { d := NewAboutData(version) return func(w http.ResponseWriter, r *http.Request) { - render(w, "about", d) + render(w, r, "about", d) } } diff --git a/internal/web/account.go b/internal/web/account.go index bd47ac3..6a9ec4e 100644 --- a/internal/web/account.go +++ b/internal/web/account.go @@ -20,6 +20,22 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) +// The errors from the account service and the store that these pages show a +// person as they are, in that person's language. +func init() { + registerErrorKey(account.ErrBadCredentials, "login.err.bad_credentials") + registerErrorKey(account.ErrNotApproved, "login.err.not_approved") + registerErrorKey(account.ErrSuspended, "login.err.suspended") + registerErrorKey(account.ErrTooManyTries, "login.err.too_many_tries") + registerErrorKey(account.ErrBusy, "login.err.busy") + registerErrorKey(account.ErrInvalidEmail, "signup.err.invalid_email") + registerErrorKey(account.ErrSamePassword, "profile.err.same_password") + // A key's state is also its CSS class, and a membership's scope is a + // word of the store; both are looked up by name. + registerKeys("profile.state.active", "profile.state.revoked", "profile.state.expired", + "profile.scope.org", "profile.scope.team", "profile.scope.project") +} + // SessionCookie is the name of the cookie a signed-in browser carries. const SessionCookie = "llmgw_session" @@ -171,9 +187,9 @@ type signupData struct { Version string } -func (p *AccountPages) signupForm(w http.ResponseWriter, _ *http.Request) { +func (p *AccountPages) signupForm(w http.ResponseWriter, r *http.Request) { noStore(w) - render(w, "signup", signupData{MinPassword: account.MinPasswordLen, Version: p.version}) + render(w, r, "signup", signupData{MinPassword: account.MinPasswordLen, Version: p.version}) } func (p *AccountPages) signup(w http.ResponseWriter, r *http.Request) { @@ -185,33 +201,36 @@ func (p *AccountPages) signup(w http.ResponseWriter, r *http.Request) { Version: p.version, } + l := langOf(r) err := p.accounts.SignUp(r.Context(), d.Email, d.Name, r.FormValue("password")) switch { case err == nil: // The same page whether or not the address was already taken. Anything // else is an account enumerator that anyone can run. d.Done = true - case errors.Is(err, account.ErrWeakPassword), errors.Is(err, account.ErrInvalidEmail): - d.Error = err.Error() + case errors.Is(err, account.ErrWeakPassword): + // Said here rather than registered: the sentence carries the length. + d.Error = l.T("signup.err.weak_password", account.MinPasswordLen) + case errors.Is(err, account.ErrInvalidEmail): + d.Error = l.Err(err) default: // Nothing of the database's words on the page; a reference that finds // them in the log for whoever runs the gateway. ref := errorRef() p.log.Error("signup failed", "ref", ref, "err", err) - d.Error = "something went wrong on our side, and your account was not created. " + - "Try again later; if it keeps happening, give an operator this reference: " + ref + d.Error = l.T("signup.err.internal", ref) } - render(w, "signup", d) + render(w, r, "signup", d) } // --- sign in and out --- // ssoLabel is what the sign-in button names, or "" without one. -func (p *AccountPages) ssoLabel() string { +func (p *AccountPages) ssoLabel(l *Lang) string { if p.oidc == nil { return "" } - return or(p.oidc.cfg.Label, "single sign-on") + return or(p.oidc.cfg.Label, l.T("sso.default_label")) } type loginData struct { @@ -226,36 +245,37 @@ type loginData struct { func (p *AccountPages) loginForm(w http.ResponseWriter, r *http.Request) { noStore(w) - render(w, "login", loginData{SSO: p.ssoLabel(), SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r)}) + render(w, r, "login", loginData{SSO: p.ssoLabel(langOf(r)), SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r)}) } func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) { noStore(w) + l := langOf(r) email := strings.TrimSpace(r.FormValue("email")) if !loginTokenOK(r) { // Not this site's form: another one posting a visitor into an // account of its choosing, or a form left open past its hour. - renderStatus(w, http.StatusBadRequest, "login", loginData{SSO: p.ssoLabel(), Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version, - Error: "the form had expired; sign in again", CSRF: setLoginToken(w, r)}) + renderStatus(w, r, http.StatusBadRequest, "login", loginData{SSO: p.ssoLabel(l), Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version, + Error: l.T("login.err.form_expired"), CSRF: setLoginToken(w, r)}) return } ctx := account.WithClient(r.Context(), clientAddr(r)) token, csrf, err := p.accounts.SignIn(ctx, email, r.FormValue("password")) if err != nil { - d := loginData{SSO: p.ssoLabel(), Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r)} + d := loginData{SSO: p.ssoLabel(l), Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r)} switch { case errors.Is(err, account.ErrBadCredentials), errors.Is(err, account.ErrNotApproved), errors.Is(err, account.ErrSuspended), errors.Is(err, account.ErrTooManyTries), errors.Is(err, account.ErrBusy): - d.Error = err.Error() + d.Error = l.Err(err) default: p.log.Error("sign-in failed", "err", err) - d.Error = "something went wrong; try again" + d.Error = l.T("login.err.try_again") } - renderStatus(w, http.StatusUnauthorized, "login", d) + renderStatus(w, r, http.StatusUnauthorized, "login", d) return } // Through the provider only, except an administrator's way in for when @@ -263,9 +283,9 @@ func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) { if p.oidc != nil && p.oidc.cfg.NoPasswords { if _, u, err := p.accounts.Resolve(ctx, token); err != nil || !u.IsAdmin { _ = p.accounts.SignOut(ctx, token) - renderStatus(w, http.StatusForbidden, "login", loginData{SSO: p.ssoLabel(), Email: email, + renderStatus(w, r, http.StatusForbidden, "login", loginData{SSO: p.ssoLabel(l), Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r), - Error: "this gateway signs people in with " + p.ssoLabel() + ", not passwords"}) + Error: l.T("login.err.passwords_off", p.ssoLabel(l))}) return } } @@ -369,14 +389,16 @@ func (p *AccountPages) createKey(w http.ResponseWriter, r *http.Request) { return } + l := langOf(r) name := strings.TrimSpace(r.FormValue("name")) if name == "" { + // Stored, and read by every tool that lists keys: not translated. name = "untitled" } project := r.FormValue("context") if project == personalValue { if p.personalOff { - p.renderProfile(w, r, sess, user, "", "personal keys are off on this gateway; choose a project") + p.renderProfile(w, r, sess, user, "", l.T("profile.err.personal_off")) return } project = "" @@ -384,17 +406,17 @@ func (p *AccountPages) createKey(w http.ResponseWriter, r *http.Request) { secret, k, err := store.NewKey(randomKeyID(), name, project) if err != nil { p.log.Error("could not mint a key", "err", err) - p.renderProfile(w, r, sess, user, "", "something went wrong; try again") + p.renderProfile(w, r, sess, user, "", l.T("profile.err.try_again")) return } k.CreatedAt = time.Now() if err := p.keys.CreateUserKey(r.Context(), user.ID, k); err != nil { if errors.Is(err, store.ErrNotMember) { - p.renderProfile(w, r, sess, user, "", "you are not a member of that project") + p.renderProfile(w, r, sess, user, "", l.T("profile.err.not_member")) return } p.log.Error("could not store a key", "err", err) - p.renderProfile(w, r, sess, user, "", "something went wrong; try again") + p.renderProfile(w, r, sess, user, "", l.T("profile.err.try_again")) return } p.log.Info("a user created a key", "user", user.ID, "key", k.ID) @@ -457,8 +479,8 @@ func (p *AccountPages) render(w http.ResponseWriter, r *http.Request, p.log.Error("could not list keys", "err", err) } d.Keys = keys - p.fillPlaces(r.Context(), user, &d) - render(w, "profile", d) + p.fillPlaces(r.Context(), langOf(r), user, &d) + render(w, r, "profile", d) } // personalValue is how a form names the person's own, project-less use. @@ -473,7 +495,7 @@ func (p *AccountPages) SetPersonalOff(off bool) { p.personalOff = off } // fillPlaces adds where the person can make keys, where their keys are, and // what applies in each place, resolved the way a request is: the whole chain // above a project, per-member limits, and the person's own. -func (p *AccountPages) fillPlaces(ctx context.Context, user store.User, d *profileData) { +func (p *AccountPages) fillPlaces(ctx context.Context, l *Lang, user store.User, d *profileData) { d.Where = map[string]string{} projects, err := p.keys.ProjectsFor(ctx, user.ID) if err != nil { @@ -484,7 +506,7 @@ func (p *AccountPages) fillPlaces(ctx context.Context, user store.User, d *profi d.Where[pp.ID] = pp.Path } if !p.personalOff { - d.Contexts = append(d.Contexts, ChatContext{Value: personalValue, Label: "Personal"}) + d.Contexts = append(d.Contexts, ChatContext{Value: personalValue, Label: l.T("profile.personal")}) } if d.Memberships, err = p.keys.MembershipsOf(ctx, user.ID); err != nil { p.log.Warn("could not list a person's memberships", "err", err) @@ -498,45 +520,58 @@ func (p *AccountPages) fillPlaces(ctx context.Context, user store.User, d *profi if err != nil { continue } - rows := limitRows(e) + rows := limitRows(l, e) if p.guardFor != nil { names, keepNothing := p.guardFor(guard.Subject{UserID: user.ID, ProjectID: e.ProjectID, TeamID: e.TeamID, OrgID: e.OrgID}) if names != "" { v := strings.ReplaceAll(names, ",", ", ") if keepNothing { - v += " — nothing is kept: no conversations, notes or outside models" + v = l.T("selfuse.limit.keep_nothing", v) } - rows = append(rows, limitRow{"Guardrails", v}) + rows = append(rows, limitRow{l.T("selfuse.limit.guard"), v}) } } d.LimitsBy = append(d.LimitsBy, contextLimits{Label: c.Label, Rows: rows, project: project, chain: e.Chain}) } - p.fillOwnUse(ctx, user, d) + p.fillOwnUse(ctx, l, user, d) } -func limitRows(e store.Effective) []limitRow { +func limitRows(l *Lang, e store.Effective) []limitRow { var out []limitRow if e.RPM != nil { - out = append(out, limitRow{"Requests per minute", itoa(*e.RPM)}) + out = append(out, limitRow{l.T("selfuse.limit.rpm"), l.Int(int64(*e.RPM))}) } if e.TPM != nil { - out = append(out, limitRow{"Tokens per minute", itoa(*e.TPM)}) + out = append(out, limitRow{l.T("selfuse.limit.tpm"), l.Int(int64(*e.TPM))}) } if e.Budget != nil { - out = append(out, limitRow{"Budget", units(*e.Budget) + " per " + string(e.BudgetPeriod)}) + out = append(out, limitRow{l.T("selfuse.limit.budget"), budgetPer(l, l.Units(*e.Budget), e.BudgetPeriod)}) } if e.AllowedModels != nil { if len(e.AllowedModels) == 0 { - out = append(out, limitRow{"Models", "none"}) + out = append(out, limitRow{l.T("selfuse.limit.models"), l.T("common.none")}) } else { - out = append(out, limitRow{"Models", strings.Join(e.AllowedModels, ", ")}) + out = append(out, limitRow{l.T("selfuse.limit.models"), strings.Join(e.AllowedModels, ", ")}) } } return out } +// budgetPer is a budget with the period it renews over: "50.00 per month". +func budgetPer(l *Lang, amount string, p store.Period) string { + switch p { + case store.PeriodDay: + return l.T("selfuse.limit.budget_day", amount) + case store.PeriodMonth: + return l.T("selfuse.limit.budget_month", amount) + case store.PeriodNone: + return l.T("selfuse.limit.budget_none", amount) + } + return l.T("selfuse.limit.budget_other", amount, string(p)) +} + // --- helpers --- func (p *AccountPages) session(r *http.Request) (store.Session, store.User, bool) { @@ -640,17 +675,19 @@ func (p *AccountPages) changePassword(w http.ResponseWriter, r *http.Request) { err := p.accounts.ChangePassword(r.Context(), p.passwords, user, r.FormValue("current"), r.FormValue("new")) if err != nil { + l := langOf(r) d := profileData{} switch { case errors.Is(err, account.ErrBadCredentials): - d.PasswordError = "that is not your current password" - case errors.Is(err, account.ErrWeakPassword), - errors.Is(err, account.ErrSamePassword), + d.PasswordError = l.T("profile.err.wrong_password") + case errors.Is(err, account.ErrWeakPassword): + d.PasswordError = l.T("signup.err.weak_password", account.MinPasswordLen) + case errors.Is(err, account.ErrSamePassword), errors.Is(err, account.ErrTooManyTries): - d.PasswordError = err.Error() + d.PasswordError = l.Err(err) default: p.log.Error("could not change a password", "err", err) - d.PasswordError = "something went wrong; try again" + d.PasswordError = l.T("profile.err.try_again") } p.render(w, r, sess, user, d) return @@ -704,8 +741,8 @@ func (p *AccountPages) renderWithCSRF(w http.ResponseWriter, r *http.Request, p.log.Error("could not list keys", "err", err) } d.Keys = keys - p.fillPlaces(r.Context(), user, &d) - render(w, "profile", d) + p.fillPlaces(r.Context(), langOf(r), user, &d) + render(w, r, "profile", d) } // SetLlamaCppVersion tells the pages which llama.cpp build the nodes run. diff --git a/internal/web/admin_capacity.go b/internal/web/admin_capacity.go index a97d066..38ffe97 100644 --- a/internal/web/admin_capacity.go +++ b/internal/web/admin_capacity.go @@ -2,7 +2,6 @@ package web import ( "context" - "fmt" "net/http" "strconv" "strings" @@ -39,21 +38,21 @@ type capacityForm struct { LGPUs, LCores, LMem string } -func (p *AccountPages) capacitySection(ctx context.Context, d *adminQuotasData, editing string) { +func (p *AccountPages) capacitySection(ctx context.Context, l *Lang, d *adminQuotasData, editing string) { if p.capacity == nil { return } d.HasCapacity = true views, err := p.capacity.QuotaViews(ctx) if err != nil { - d.Error = err.Error() + d.Error = l.Err(err) return } for _, v := range views { d.Capacity = append(d.Capacity, capacityRow{QuotaView: v, - GPUs: capCell(v.Holds.GPUs, v.Guarantee.GPUs, v.Limit.GPUs), - Cores: capCell(v.Holds.CPUCores, v.Guarantee.CPUCores, v.Limit.CPUCores), - Memory: capCell(v.Holds.MemoryGB, v.Guarantee.MemoryGB, v.Limit.MemoryGB)}) + 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)}) if v.Scope == editing { d.CapForm = capacityForm{Scope: v.Scope, GPUs: intStr(v.Guarantee.GPUs), Cores: intStr(v.Guarantee.CPUCores), Memory: intStr(v.Guarantee.MemoryGB), @@ -61,21 +60,25 @@ func (p *AccountPages) capacitySection(ctx context.Context, d *adminQuotasData, } } if d.Scopes, err = p.capacity.Scopes(ctx); err != nil { - d.Error = err.Error() + d.Error = l.Err(err) } } +// capCell is a capacity figure in English, for a caller with no request to +// take a language from. +func capCell(holds int, g, l *int) string { return english.capCell(holds, g, l) } + // capCell is "6 of 8 (at most 16)": held, guaranteed, limit. -func capCell(holds int, g, l *int) string { +func (lang *Lang) capCell(holds int, g, l *int) string { if g == nil && l == nil { return "" } - out := strconv.Itoa(holds) + out := lang.Int(int64(holds)) if g != nil { - out += " of " + strconv.Itoa(*g) + out = lang.T("capacity.cell.of", out, lang.Int(int64(*g))) } if l != nil { - out += fmt.Sprintf(" (at most %d)", *l) + out = lang.T("capacity.cell.at_most", out, lang.Int(int64(*l))) } return out } @@ -87,27 +90,30 @@ func intStr(v *int) string { return strconv.Itoa(*v) } -// applyCapacity sets or clears one scope's capacity quota from the form. +// applyCapacity sets or clears one scope's capacity quota from the form, and +// says what it did in the request's language. What the admin API refuses is +// its own English. func (p *AccountPages) applyCapacity(r *http.Request, admin store.User) (string, error) { + l := langOf(r) scope := strings.TrimSpace(r.FormValue("scope")) if scope == "" { - return "", fmt.Errorf("choose where the quota applies") + return "", quotaRefusal{key: "capacity.err.scope"} } if r.FormValue("clear") != "" { key, err := p.capacity.ClearQuota(r.Context(), scope) if err != nil { return "", err } - return "capacity quota removed from " + key, nil + return l.T("capacity.msg.removed", key), nil } - field := func(name, what string) (*int, error) { + field := func(name string) (*int, error) { v := strings.TrimSpace(r.FormValue(name)) if v == "" { return nil, nil } n, err := strconv.Atoi(v) if err != nil || n < 0 { - return nil, fmt.Errorf("%s must be a whole number, or blank for none", what) + return nil, quotaRefusal{key: "capacity.err." + name} } return &n, nil } @@ -117,16 +123,15 @@ func (p *AccountPages) applyCapacity(r *http.Request, admin store.User) (string, for _, f := range []struct { dst **int name string - what string }{ - {&req.Guarantee.GPUs, "gpus", "guaranteed GPUs"}, - {&req.Guarantee.CPUCores, "cores", "guaranteed cores"}, - {&req.Guarantee.MemoryGB, "memory", "guaranteed memory"}, - {&req.Limit.GPUs, "limit_gpus", "the GPU limit"}, - {&req.Limit.CPUCores, "limit_cores", "the core limit"}, - {&req.Limit.MemoryGB, "limit_memory", "the memory limit"}, + {&req.Guarantee.GPUs, "gpus"}, + {&req.Guarantee.CPUCores, "cores"}, + {&req.Guarantee.MemoryGB, "memory"}, + {&req.Limit.GPUs, "limit_gpus"}, + {&req.Limit.CPUCores, "limit_cores"}, + {&req.Limit.MemoryGB, "limit_memory"}, } { - if *f.dst, err = field(f.name, f.what); err != nil { + if *f.dst, err = field(f.name); err != nil { return "", err } } @@ -134,5 +139,5 @@ func (p *AccountPages) applyCapacity(r *http.Request, admin store.User) (string, if err != nil { return "", err } - return "capacity quota set on " + key + "; it takes effect within half a minute", nil + return l.T("capacity.msg.set", key), nil } diff --git a/internal/web/admin_guard.go b/internal/web/admin_guard.go index d9272e7..2d84033 100644 --- a/internal/web/admin_guard.go +++ b/internal/web/admin_guard.go @@ -52,7 +52,7 @@ var guardWindows = map[string]time.Duration{"24h": 24 * time.Hour, "7d": 7 * 24 func (p *AccountPages) RegisterAdminGuard(mux *http.ServeMux, s GuardAdminSource, library guard.Config) { p.hasGuard = true mux.HandleFunc("GET /admin/guard", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { - ctx := r.Context() + ctx, l := r.Context(), langOf(r) d := guardPageData{Nav: p.navFor(r, admin, "guard"), Version: p.version, Window: r.URL.Query().Get("window"), Windows: []string{"24h", "7d", "30d"}, Default: library.Default} since, ok := guardWindows[d.Window] @@ -64,7 +64,7 @@ func (p *AccountPages) RegisterAdminGuard(mux *http.ServeMux, s GuardAdminSource } slices.Sort(d.Policies) - where := map[string]string{"": "personal"} + where := map[string]string{"": l.T("guard.personal")} if projects, err := s.AllProjects(ctx); err == nil { for _, pp := range projects { where[pp.ID] = pp.Path @@ -82,27 +82,62 @@ func (p *AccountPages) RegisterAdminGuard(mux *http.ServeMux, s GuardAdminSource if a.Scope == "project" { who = name(a.ScopeID) } - d.Attachments = append(d.Attachments, guardAttachmentRow{Scope: a.Scope, Who: who, Policy: a.Policy}) + d.Attachments = append(d.Attachments, guardAttachmentRow{Scope: guardScopeWord(l, a.Scope), Who: who, Policy: a.Policy}) } } counts, err := s.GuardCounts(ctx, time.Now().Add(-since)) if err != nil { - d.Error = err.Error() + d.Error = l.Err(err) } for _, c := range counts { - d.Counts = append(d.Counts, guardCountRow{Where: name(c.ProjectID), Action: c.Action, + d.Counts = append(d.Counts, guardCountRow{Where: name(c.ProjectID), Action: guardActionWord(l, c.Action), Policy: c.Policy, Category: c.Category, Count: c.Count}) } events, err := s.GuardEvents(ctx, time.Now().Add(-since), 100) if err != nil { - d.Error = err.Error() + d.Error = l.Err(err) } for _, e := range events { - d.Events = append(d.Events, guardEventRow{At: e.At.UTC().Format("2006-01-02 15:04:05"), - RequestID: e.RequestID, Where: name(e.ProjectID), Model: e.Model, Action: e.Action, + // The day as the reader writes one; the seconds stay, because + // events a second apart are what this table tells apart. + at := e.At.UTC() + d.Events = append(d.Events, guardEventRow{At: l.Date(at) + " " + at.Format("15:04:05"), + RequestID: e.RequestID, Where: name(e.ProjectID), Model: e.Model, Action: guardActionWord(l, e.Action), Policy: e.Policy, Category: e.Category, Status: e.Status, Ms: e.GuardMs}) } noStore(w) - render(w, "admin_guard", d) + render(w, r, "admin_guard", d) })) } + +// guardScopeWord names the kind of scope a policy is attached to in the +// reader's language. A kind this page does not know is shown as stored. +func guardScopeWord(l *Lang, scope string) string { + switch scope { + case "org": + return l.T("guard.scope.org") + case "team": + return l.T("guard.scope.team") + case "project": + return l.T("guard.scope.project") + case "user": + return l.T("guard.scope.user") + case "key": + return l.T("guard.scope.key") + } + return scope +} + +// guardActionWord is what a policy did, in the reader's language. The stored +// value stays the English word the events are recorded and queried by. +func guardActionWord(l *Lang, action string) string { + switch action { + case "flagged": + return l.T("guard.action.flagged") + case "redacted": + return l.T("guard.action.redacted") + case "blocked": + return l.T("guard.action.blocked") + } + return action +} diff --git a/internal/web/admin_guard_test.go b/internal/web/admin_guard_test.go index 4c4036a..338b12f 100644 --- a/internal/web/admin_guard_test.go +++ b/internal/web/admin_guard_test.go @@ -8,7 +8,7 @@ import ( func TestTheGuardrailsPageShowsNamesAndCounts(t *testing.T) { rec := httptest.NewRecorder() - render(rec, "admin_guard", guardPageData{ + render(rec, nil, "admin_guard", guardPageData{ Nav: Nav{Admin: true, Guard: true, Current: "guard"}, Window: "7d", Windows: []string{"24h", "7d"}, Policies: []string{"base", "finance"}, Default: []string{"base"}, Attachments: []guardAttachmentRow{{Scope: "project", Who: "Acme/fin/payroll", Policy: "finance"}}, diff --git a/internal/web/admin_mirror.go b/internal/web/admin_mirror.go index 4fd8727..e84ebbd 100644 --- a/internal/web/admin_mirror.go +++ b/internal/web/admin_mirror.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "html/template" "net/http" "slices" @@ -238,10 +237,12 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) { d := adminMirrorData{CSRF: csrfFor(r), Inline: true, HasRegistry: true} p.openRepo(r, &d, repo) if d.RegistryErr != "" { - http.Error(w, "The registry: "+d.RegistryErr, http.StatusBadGateway) + // Read by the page's own script, which shows it beneath the + // row, so it is in the page's language. + http.Error(w, langOf(r).T("explore.registry_err", d.RegistryErr), http.StatusBadGateway) return } - render(w, "mirror_repo", d) + render(w, r, "mirror_repo", d) })) mux.HandleFunc("POST /admin/mirror/fetch", p.mirrorAction(m, @@ -249,9 +250,10 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) { // One form posts one source or fifteen. A sharded model is one // decision, not one per shard, and the mirror fetches each file // independently anyway. + l := langOf(r) sources, files := r.Form["source"], r.Form["file"] if len(sources) == 0 || strings.TrimSpace(sources[0]) == "" { - return "", errors.New("a source URL is required") + return "", errors.New(l.T("mirror.err.source_required")) } model := strings.TrimSpace(r.FormValue("model")) version := strings.TrimSpace(r.FormValue("version")) @@ -261,7 +263,7 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) { // of download and a replica failing on every node. if r.FormValue("force") == "" { if v := p.checkSource(ctx, strings.TrimSpace(sources[0])); !v.Servable { - return "", fmt.Errorf("not fetched: %s. Tick \"fetch anyway\" to take it regardless", v.Reason) + return "", errors.New(l.T("mirror.err.not_fetched", v.Reason)) } } @@ -278,25 +280,25 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) { if _, err := m.Fetch(ctx, req); err != nil { // Reported with what already went, so a set that fails on // its fourth shard does not read as though nothing started. - return "", fmt.Errorf("%d of %d started, then %s: %w", - started, len(sources), mirror.LastPathElement(src), err) + return "", errors.New(l.T("mirror.err.partly_started", + started, len(sources), mirror.LastPathElement(src), l.Err(err))) } started++ } // Named as started, not as done. The mirror answered 202 and the // bytes are still moving; a message saying "added" would invite // somebody to point the catalog at a partial file. - return fmt.Sprintf("started downloading %s/%s (%s)", - model, version, plural(started, "file", "files")), nil + return l.T("mirror.msg.started", model, version, l.N("mirror.files", int64(started))), nil })) if d.Catalog != nil { // How a model runs, changed where the file it runs is seen. mux.HandleFunc("POST /admin/mirror/mode", p.mirrorAction(m, func(ctx context.Context, admin store.User, r *http.Request) (string, error) { + l := langOf(r) name, mode := r.FormValue("model"), r.FormValue("mode") if name == "" || (mode != modeAlways && mode != modeOnDemand && mode != modeStopped) { - return "", errors.New("choose a model and how it should run") + return "", errors.New(l.T("mirror.err.choose_mode")) } replicas, _ := strconv.Atoi(r.FormValue("replicas")) sc := scalingFor(mode, replicas, 0) @@ -305,11 +307,11 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) { } switch mode { case modeOnDemand: - return name + " now loads when asked for and stops after 15 minutes unused", nil + return l.T("mirror.msg.ondemand", name), nil case modeStopped: - return name + " is stopped; its replicas are being taken down", nil + return l.T("mirror.msg.stopped", name), nil } - return name + " now runs all the time", nil + return l.T("mirror.msg.always", name), nil })) mux.HandleFunc("POST /admin/mirror/replicas", p.mirrorAction(m, p.setModel)) @@ -317,7 +319,8 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) { mux.HandleFunc("POST /admin/mirror/serve", p.mirrorAction(m, func(ctx context.Context, admin store.User, r *http.Request) (string, error) { - model, err := buildModel(r.FormValue) + l := langOf(r) + model, err := buildModel(l, r.FormValue) if err != nil { return "", err } @@ -326,9 +329,7 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) { // speed with nothing saying why. if model.Profiles[0].CPUOnly() && r.FormValue("cpu_ok") == "" { if n := freeGPUs(p.fleet); n > 0 { - return "", fmt.Errorf("this fleet has %s free and %s was set to run on the CPU; "+ - "choose a GPU placement, or tick \"run on the CPU anyway\"", - plural(n, "GPU", "GPUs"), model.Name) + return "", errors.New(l.N("serve.err.cpu_with_free_gpus", int64(n), model.Name)) } } // The same check `llmgw ctl model add` makes. This page builds @@ -345,9 +346,8 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) { } // Placed on the next pass, not now: saying it is serving would // be wrong for however long the weights take to reach a node. - return fmt.Sprintf( - "%s added to the catalog, %s: %s; it is placed on the next pass", - model.Name, plural(model.Scaling.Min, "replica", "replicas"), describeServe(model)), nil + return l.T("serve.msg.added", model.Name, + l.N("serve.replicas_n", int64(model.Scaling.Min)), describeServe(l, model)), nil })) } @@ -358,8 +358,8 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) { if err != nil { return "", err } - return "removed " + model + "/" + ver + - " (" + plural(len(removed), "file", "files") + ")", nil + l := langOf(r) + return l.T("mirror.msg.removed", model, ver, l.N("mirror.files", int64(len(removed)))), nil })) } @@ -386,10 +386,10 @@ func (p *AccountPages) mirrorAction(m MirrorAdmin, p.log.Warn("a mirror action was refused", "admin", admin.Email, "mirror", m.Addr(), "err", err) if wantJSON { - writeJSONStatus(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) + writeJSONStatus(w, http.StatusUnprocessableEntity, map[string]string{"error": langOf(r).Err(err)}) return } - p.renderMirror(w, r, m, "", err.Error()) + p.renderMirror(w, r, m, "", langOf(r).Err(err)) return } p.log.Info("the mirror was changed", @@ -405,6 +405,7 @@ func (p *AccountPages) mirrorAction(m MirrorAdmin, func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request, m MirrorAdmin, msg, errMsg string, ) { + l := langOf(r) d := adminMirrorData{ Nav: navFrom(r, "mirror"), Addr: m.Addr(), @@ -430,10 +431,10 @@ func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request, p.log.Error("could not read the mirror", "mirror", m.Addr(), "err", err) d.Unreachable = true if d.Error == "" { - d.Error = err.Error() + d.Error = l.Err(err) } - d.Tabs = mirrorTabs(d) - render(w, "admin_mirror", d) + d.Tabs = mirrorTabs(l, d) + render(w, r, "admin_mirror", d) return } @@ -521,7 +522,7 @@ func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request, for i := range d.Rows { d.Rows[i].Open = openKey(d.Rows[i].Model, d.Rows[i].Version) if open != "" && d.Rows[i].Open == open && d.Rows[i].State == mirror.Present { - d.Rows[i].Detail = p.heldDetailFor(r.Context(), d.Rows[i]) + d.Rows[i].Detail = p.heldDetailFor(r.Context(), l, d.Rows[i]) d.Rows[i].Detail.addAdapterChoices(d.Rows[i], d.Rows) } } @@ -539,7 +540,7 @@ func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request, if key != want || d.Rows[i].State != mirror.Present { continue } - form := planServe(d.Rows[i].Status, + form := planServe(l, d.Rows[i].Status, byVersion[d.Rows[i].Model+"/"+d.Rows[i].Version], got.Base, p.fleet, p.catalog, func(src string) gguf.Verdict { return p.checkSource(r.Context(), src) }) d.Rows[i].Serve = &form break @@ -572,8 +573,8 @@ func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request, if d.Fetching > 0 && d.Tab == tabHeld && open == "" { d.RefreshSec = 5 } - d.Tabs = mirrorTabs(d) - render(w, "admin_mirror", d) + d.Tabs = mirrorTabs(l, d) + render(w, r, "admin_mirror", d) } const ( @@ -617,25 +618,25 @@ func mirrorTabFor(r *http.Request, errMsg string) string { return tabHeld } -func mirrorTabs(d adminMirrorData) []mirrorTab { - held := mirrorTab{Label: "Held", Href: "/admin/mirror?tab=held", Active: d.Tab == tabHeld} +func mirrorTabs(l *Lang, d adminMirrorData) []mirrorTab { + held := mirrorTab{Label: l.T("mirror.tab.held"), Href: "/admin/mirror?tab=held", Active: d.Tab == tabHeld} switch { case d.Unreachable: - held.Count = "unreachable" + held.Count = l.T("mirror.tab.unreachable") case d.Fetching > 0: - held.Count = fmt.Sprintf("%d · %d downloading", d.Present, d.Fetching) + held.Count = l.T("mirror.tab.downloading", l.Int(int64(d.Present)), l.Int(int64(d.Fetching))) default: - held.Count = strconv.Itoa(d.Present) + held.Count = l.Int(int64(d.Present)) } if d.Failed > 0 { - held.Count += fmt.Sprintf(" · %d failed", d.Failed) + held.Count += " · " + l.T("mirror.tab.failed", l.Int(int64(d.Failed))) } tabs := []mirrorTab{held} if d.HasRegistry { - tabs = append(tabs, mirrorTab{Label: "Explore Hugging Face", + tabs = append(tabs, mirrorTab{Label: l.T("mirror.tab.explore"), Href: "/admin/mirror?tab=explore", Active: d.Tab == tabExplore}) } - return append(tabs, mirrorTab{Label: "Fetch by URL", + return append(tabs, mirrorTab{Label: l.T("mirror.tab.fetch"), Href: "/admin/mirror?tab=fetch", Active: d.Tab == tabFetch}) } @@ -752,7 +753,7 @@ func (p *AccountPages) explore(r *http.Request, d *adminMirrorData) { func (p *AccountPages) openRepo(r *http.Request, d *adminMirrorData, repo string) { got, err := p.registry.Repo(r.Context(), repo) if err != nil { - d.RegistryErr = err.Error() + d.RegistryErr = langOf(r).Err(err) return } d.Repo = &got @@ -777,7 +778,7 @@ func (p *AccountPages) openRepo(r *http.Request, d *adminMirrorData, repo string // the same branch lands somewhere else. Version: got.ShortRevision(), } - rs.Verdict = p.verdictFor(got.Architecture, set.Name) + rs.Verdict = p.verdictFor(langOf(r), got.Architecture, set.Name) for _, f := range set.Files { rs.Sources = append(rs.Sources, repoSource{URL: got.DownloadURL("", f.Path), Path: f.Path}) @@ -847,7 +848,7 @@ func defaultModelName(repoID string) string { return hf.ModelName(repoID) } // verdictFor judges a registry file set by the repository's architecture. A // vision projector is its own architecture whatever the repository says. -func (p *AccountPages) verdictFor(arch, name string) gguf.Verdict { +func (p *AccountPages) verdictFor(l *Lang, arch, name string) gguf.Verdict { if strings.Contains(strings.ToLower(name), "mmproj") { return gguf.LlamaCpp(p.llamaVersion, "clip") } @@ -855,7 +856,7 @@ func (p *AccountPages) verdictFor(arch, name string) gguf.Verdict { // file like them, so the repository's architecture would pass it. if strings.Contains(strings.ToLower(name), "imatrix") { return gguf.Verdict{Checked: true, - Reason: "it is an importance matrix for quantising, not a model"} + Reason: l.T("card.imatrix")} } if arch == "" || !gguf.IsGGUF(name) { return gguf.Verdict{Servable: true} diff --git a/internal/web/admin_nodeengine.go b/internal/web/admin_nodeengine.go index 60a5100..04ffdcc 100644 --- a/internal/web/admin_nodeengine.go +++ b/internal/web/admin_nodeengine.go @@ -28,22 +28,31 @@ func (p *AccountPages) RegisterNodeEngines(mux *http.ServeMux, a NodeEngineAdmin q := url.Values{} if err := a.SetNodeEngine(r.Context(), node, engine, build, admin.Email); err != nil { p.log.Warn("an engine build was not chosen", "admin", admin.Email, "node", node, "err", err) + // The control plane's reason, as it gave it: in English. q.Set("problem", node+": "+err.Error()) } else { p.log.Info("an engine build was chosen", "admin", admin.Email, "node", node, "engine", engine, "build", build) - q.Set("notice", node+" installs "+engine+" "+buildWords(build)+ - "; the node is told now, and installs it in the background") + // Said in the language of the person who chose, since the + // sentence travels to the page in the address. + q.Set("notice", buildNotice(langOf(r), node, engine, build)) } http.Redirect(w, r, "/admin?"+q.Encode(), http.StatusSeeOther) })) } -func buildWords(b string) string { - switch b { +// buildNotice says what a node was told to install. A whole sentence for +// each kind of choice, because where the build goes in it is the +// language's business. +func buildNotice(l *Lang, node, engine, build string) string { + switch build { case "auto": - return "in the build its hardware runs" + return l.T("nodeengine.notice.auto", node, engine) case "none": - return "no longer" + return l.T("nodeengine.notice.none", node, engine) } - return "built for " + map[string]string{"cu13": "CUDA 13", "cu129": "CUDA 12.9"}[b] + name := map[string]string{"cu13": "CUDA 13", "cu129": "CUDA 12.9", "cu12": "CUDA 12"}[build] + if name == "" { + name = build + } + return l.T("nodeengine.notice.build", node, engine, name) } diff --git a/internal/web/admin_people.go b/internal/web/admin_people.go index 5eae074..3fe5ba6 100644 --- a/internal/web/admin_people.go +++ b/internal/web/admin_people.go @@ -79,6 +79,13 @@ type peopleDetail struct { 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{ @@ -116,7 +123,7 @@ func (p *AccountPages) renderPeople(w http.ResponseWriter, r *http.Request, admi p.renderPeople(w, r, admin, v) return } - render(w, "admin_people", d) + render(w, r, "admin_people", d) } // usageGrouping is the grouping a branch's or a person's use is read under. @@ -141,15 +148,16 @@ func (p *AccountPages) detail(r *http.Request, sel string, u *adminUsersData) *p return nil } ctx := r.Context() + l := langOf(r) d := &peopleDetail{Kind: store.Scope(kind), ID: id, CSRF: csrfFor(r), - UsageWindow: "last 7 days", QuotaWindow: "30 days"} + 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, d) + p.limitsDetail(ctx, l, d) } if p.capacity != nil && d.Kind != store.ScopeUser { d.HasCapacity = true @@ -157,16 +165,16 @@ func (p *AccountPages) detail(r *http.Request, sel string, u *adminUsersData) *p for _, v := range views { if v.Scope == sel { c := capacityRow{QuotaView: v, - GPUs: capCell(v.Holds.GPUs, v.Guarantee.GPUs, v.Limit.GPUs), - Cores: capCell(v.Holds.CPUCores, v.Guarantee.CPUCores, v.Limit.CPUCores), - Memory: capCell(v.Holds.MemoryGB, v.Guarantee.MemoryGB, v.Limit.MemoryGB)} + 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, d) + p.usageDetail(ctx, l, d) } return d } @@ -205,7 +213,7 @@ func (p *AccountPages) nameDetail(d *peopleDetail, u *adminUsersData) bool { } // limitsDetail is the branch's own quota row, or the person's own limit. -func (p *AccountPages) limitsDetail(ctx context.Context, d *peopleDetail) { +func (p *AccountPages) limitsDetail(ctx context.Context, l *Lang, d *peopleDetail) { s := p.quotaStore limits, err := s.AllLimits(ctx) if err != nil { @@ -214,7 +222,7 @@ func (p *AccountPages) limitsDetail(ctx context.Context, d *peopleDetail) { } since := time.Now().Add(-quotaWindow) if d.Kind == store.ScopeUser { - for _, row := range p.personRows(ctx, s, limits, since, "") { + for _, row := range p.personRowsIn(ctx, l, s, limits, since, "") { if row.ID == d.ID { row := row d.Person = &row @@ -226,11 +234,11 @@ func (p *AccountPages) limitsDetail(ctx context.Context, d *peopleDetail) { if err != nil { return } - for _, row := range quotaRows(tree, limits, p.spendByScope(ctx, s, since)) { + 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 l, ok := limits[store.LimitOwner{Scope: ms, ID: row.ID}.CounterKey()]; ok { - row.MemberRaw, row.Member = fieldsOf(l), describeLimits(l) + 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 @@ -239,7 +247,7 @@ func (p *AccountPages) limitsDetail(ctx context.Context, d *peopleDetail) { } // usageDetail is the branch's or person's use over the last week, drawn. -func (p *AccountPages) usageDetail(ctx context.Context, d *peopleDetail) { +func (p *AccountPages) usageDetail(ctx context.Context, l *Lang, d *peopleDetail) { g, ok := usageGrouping(d.Kind) if !ok { return @@ -260,7 +268,7 @@ func (p *AccountPages) usageDetail(ctx context.Context, d *peopleDetail) { for _, s := range rows { if s.ID == d.ID { var table []usageRow - d.Requests, d.Tokens, d.Spend, table = usageCharts([]store.UsageSeries{s}) + d.Requests, d.Tokens, d.Spend, table = usageChartsIn(l, []store.UsageSeries{s}) d.Totals = table[0] return } diff --git a/internal/web/admin_quotas.go b/internal/web/admin_quotas.go index b3bcbf3..4e0b27c 100644 --- a/internal/web/admin_quotas.go +++ b/internal/web/admin_quotas.go @@ -3,7 +3,6 @@ package web import ( "context" "errors" - "fmt" "net/http" "strconv" "strings" @@ -48,10 +47,14 @@ type personRow struct { Budget, Period string Models string HasAny bool - Spend string - Percent int - HasPct bool - Editing 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. @@ -71,6 +74,9 @@ type quotaRow struct { 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 @@ -143,6 +149,40 @@ type adminQuotasData struct { 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 @@ -178,7 +218,7 @@ func (p *AccountPages) RegisterAdminQuotas(mux *http.ServeMux, s QuotaAdmin, pri 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, "", err.Error()) + p.renderQuotas(w, r, admin, "", langOf(r).quotaRefused(err)) return } p.renderQuotas(w, r, admin, msg, "") @@ -195,16 +235,18 @@ func (p *AccountPages) RegisterAdminQuotas(mux *http.ServeMux, s QuotaAdmin, pri 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, "", err.Error()) + p.renderQuotas(w, r, admin, "", langOf(r).quotaRefused(err)) return } - p.log.Info("capacity quota changed", "admin", admin.Email, "what", msg) + 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. +// 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") @@ -212,10 +254,10 @@ func (p *AccountPages) applyQuota(r *http.Request, s QuotaAdmin, admin store.Use case store.ScopeOrg, store.ScopeTeam, store.ScopeProject, store.ScopeUser, store.ScopeOrgMember, store.ScopeTeamMember, store.ScopeProjectMember: default: - return "", errors.New("a quota belongs to an organization, team, project or person") + return "", quotaRefusal{key: "quotas.err.scope"} } if id == "" { - return "", errors.New("which one?") + return "", quotaRefusal{key: "quotas.err.which"} } // Before the write, so the audit line can say what it was. A record that a @@ -231,44 +273,62 @@ func (p *AccountPages) applyQuota(r *http.Request, s QuotaAdmin, admin store.Use } p.log.Info("a quota was cleared", "admin", admin.Email, "scope", scope, "id", id, "was", before) - return string(scope) + " " + id + ": every limit here removed", nil + return l.T("quotas.msg.cleared", l.quotaKind(scope), id), nil } - l := store.Limits{Scope: scope, ScopeID: id} + lim := store.Limits{Scope: scope, ScopeID: id} var err error - if l.RPM, err = optInt(r.FormValue("rpm"), "requests per minute"); err != nil { + if lim.RPM, err = optInt(r.FormValue("rpm"), "quotas.err.rpm"); err != nil { return "", err } - if l.TPM, err = optInt(r.FormValue("tpm"), "tokens per minute"); err != nil { + 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 l.Budget, err = optUnits(r.FormValue("budget")); err != nil { + if lim.Budget, err = optUnits(r.FormValue("budget")); err != nil { return "", err } - if l.Budget != nil { - l.BudgetPeriod = store.Period(r.FormValue("period")) - if l.BudgetPeriod != store.PeriodDay && l.BudgetPeriod != store.PeriodMonth { - return "", errors.New("a budget needs a period: day or month") + 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 != "" { - l.AllowedModels = append(l.AllowedModels, name) + lim.AllowedModels = append(lim.AllowedModels, name) } } } - if err := s.PutLimits(r.Context(), l); err != nil { + if err := s.PutLimits(r.Context(), lim); err != nil { return "", err } - now := describeLimits(l) p.log.Info("a quota was changed", "admin", admin.Email, - "scope", scope, "id", id, "was", before, "now", now) - return string(scope) + " " + id + ": " + now, nil + "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 @@ -289,21 +349,22 @@ 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: "30 days", Message: msg, Error: errMsg, + 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(), &d, r.FormValue("cap")) + 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 = "could not read the limits" + d.Error = l.T("quotas.err.read_limits") return d } spend := p.spendByScope(r.Context(), s, time.Now().Add(-window)) @@ -311,19 +372,19 @@ func (p *AccountPages) buildQuotas(r *http.Request, s QuotaAdmin, msg, errMsg st tree, err := s.Tree(r.Context()) if err != nil { p.log.Error("could not read the tree", "err", err) - d.Error = "could not read the organizations" + d.Error = l.T("quotas.err.read_tree") } - d.Rows = quotaRows(tree, limits, spend) + d.Rows = quotaRowsIn(l, tree, limits, spend) for i := range d.Rows { ms := store.MemberScope(d.Rows[i].Scope) - if l, ok := limits[store.LimitOwner{Scope: ms, ID: d.Rows[i].ID}.CounterKey()]; ok { - d.Rows[i].MemberRaw = fieldsOf(l) - d.Rows[i].Member = describeLimits(l) + 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.personRows(r.Context(), s, limits, time.Now().Add(-window), d.Editing) - d.Personal = p.personalSummary() + 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 @@ -351,20 +412,26 @@ func (p *AccountPages) spendByScope(ctx context.Context, s scopeUsage, return out } -// quotaRows lays the tree out depth first, each row with what it sets and -// what it is held to from above. +// 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(store.ScopeOrg, o.ID, o.Name, o.Name, 0, limits, spend, nil) + 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(store.ScopeTeam, t.ID, t.Name, crumb, 1, limits, spend, + 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(store.ScopeProject, pr.ID, pr.Name, crumb+" / "+pr.Name, 2, + 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)]}, @@ -385,7 +452,7 @@ func key(scope store.Scope, id string) string { return store.LimitOwner{Scope: scope, ID: id}.CounterKey() } -func quotaRow2(scope store.Scope, id, name, crumb string, indent int, +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)] @@ -395,7 +462,8 @@ func quotaRow2(scope store.Scope, id, name, crumb string, indent int, 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, - Spend: units(spend[key(scope, id)]), + 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) @@ -422,28 +490,30 @@ func quotaRow2(scope store.Scope, id, name, crumb string, indent int, 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 = strconv.Itoa(*rpm) + row.RPMUp = lang.Int(int64(*rpm)) } if tpm != nil { - row.TPMUp = strconv.Itoa(*tpm) + row.TPMUp = lang.Int(int64(*tpm)) } if budget != nil { - row.BudgetUp = units(*budget) + row.BudgetUp = lang.Units(*budget) if period != "" { - row.BudgetUp += "/" + string(period) + row.BudgetUp += "/" + lang.budgetPeriod(period) } } var capped []string if l.RPM != nil && rpm != nil && *rpm < *l.RPM { - capped = append(capped, fmt.Sprintf("%s requests/min", row.RPMUp)) + capped = append(capped, lang.T("quotas.capped.rpm", row.RPMUp)) } if l.TPM != nil && tpm != nil && *tpm < *l.TPM { - capped = append(capped, fmt.Sprintf("%s tokens/min", row.TPMUp)) + capped = append(capped, lang.T("quotas.capped.tpm", row.TPMUp)) } if l.Budget != nil && budget != nil && *budget < *l.Budget { - capped = append(capped, "a budget of "+units(*budget)) + capped = append(capped, lang.T("quotas.capped.budget", lang.Units(*budget))) } if len(capped) > 0 { from := row.RPMFrom @@ -453,21 +523,23 @@ func quotaRow2(scope store.Scope, id, name, crumb string, indent int, if from == "" { from = row.BudgetFrom } - row.Shadowed = "above it, " + strings.Join(capped, " and ") + " still apply" + row.Shadowed = lang.T("quotas.shadowed.above", strings.Join(capped, lang.T("quotas.and"))) if from != "" && len(capped) == 1 { - row.Shadowed = fmt.Sprintf("%s caps this at %s", from, capped[0]) + row.Shadowed = lang.T("quotas.shadowed.by", from, capped[0]) } } return row } -func optInt(v, what string) (*int, error) { +// 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, fmt.Errorf("%s must be a whole number, or empty for no limit", what) + return nil, quotaRefusal{key: errKey} } return &n, nil } @@ -479,28 +551,34 @@ func optUnits(v string) (*int64, error) { } f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) if err != nil || f < 0 { - return nil, errors.New("the budget must be a number, or empty for no budget") + return nil, quotaRefusal{key: "quotas.err.budget"} } m := int64(f * 1e6) return &m, nil } -func describeLimits(l store.Limits) string { +// 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, "rpm "+strconv.Itoa(*l.RPM)) + parts = append(parts, lang.T("quotas.desc.rpm", lang.Int(int64(*l.RPM)))) } if l.TPM != nil { - parts = append(parts, "tpm "+strconv.Itoa(*l.TPM)) + parts = append(parts, lang.T("quotas.desc.tpm", lang.Int(int64(*l.TPM)))) } if l.Budget != nil { - parts = append(parts, "budget "+units(*l.Budget)+"/"+string(l.BudgetPeriod)) + parts = append(parts, lang.T("quotas.desc.budget", lang.Units(*l.Budget)+"/"+lang.budgetPeriod(l.BudgetPeriod))) } if l.AllowedModels != nil { - parts = append(parts, "models "+strings.Join(l.AllowedModels, "|")) + parts = append(parts, lang.T("quotas.desc.models", strings.Join(l.AllowedModels, "|"))) } if len(parts) == 0 { - return "no limits" + return lang.T("quotas.desc.none") } return strings.Join(parts, ", ") } @@ -519,6 +597,7 @@ func dashUnits(v *int64) string { 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), @@ -527,10 +606,35 @@ func fieldsOf(l store.Limits) quotaFields { } } -// personRows lists every approved person with the limits written for them and -// what they spent, wherever they spent it. +// 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 { @@ -551,8 +655,8 @@ func (p *AccountPages) personRows(ctx context.Context, s QuotaAdmin, 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, Spend: units(spent[u.ID]), - Editing: editing == "user:"+u.ID} + 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 } @@ -565,12 +669,12 @@ func (p *AccountPages) personRows(ctx context.Context, s QuotaAdmin, // when nobody set a limit for them. func (p *AccountPages) SetPersonalDefaults(l *store.Limits) { p.personalDefaults = l } -func (p *AccountPages) personalSummary() string { +func (p *AccountPages) personalSummary(l *Lang) string { if p.personalOff { - return "Personal use is off: people use the gateway only through their projects." + return l.T("quotas.personal.off") } if p.personalDefaults == nil { - return "Personal use has no default limit: someone with no limit of their own is bounded only by the cluster-wide defaults." + return l.T("quotas.personal.none") } - return "With no limit of their own, a person's personal use is held to " + describeLimits(*p.personalDefaults) + "." + return l.T("quotas.personal.held", l.limitsText(*p.personalDefaults)) } diff --git a/internal/web/admin_serve.go b/internal/web/admin_serve.go index 6933240..f064c03 100644 --- a/internal/web/admin_serve.go +++ b/internal/web/admin_serve.go @@ -86,7 +86,7 @@ type serveForm struct { } // planServe works out what serving this version would mean. -func planServe(st mirror.Status, files []mirror.Status, base string, +func planServe(l *Lang, st mirror.Status, files []mirror.Status, base string, fleet Source, cat CatalogAdmin, check func(source string) gguf.Verdict, ) serveForm { var header gguf.Header @@ -123,14 +123,14 @@ func planServe(st mirror.Status, files []mirror.Status, base string, switch f.Engine { case "": - f.Refusal = "nothing here reads " + extOf(st.File) + " files" + f.Refusal = l.T("serve.refuse.no_engine", extOf(st.File)) return f case "llamacpp": f.Profile = "cpu" if check != nil { v := check(f.Source) if !v.Servable { - f.Refusal = "llama.cpp on the nodes cannot run this: " + v.Reason + f.Refusal = l.T("serve.refuse.llamacpp", v.Reason) return f } header = v.Header @@ -139,7 +139,7 @@ func planServe(st mirror.Status, files []mirror.Status, base string, f.Profile = "gpu" } f.GPUs, f.Parallel = 1, defaultParallel - f.Placements, f.FreeGPUs = placementsFor(fleet, f.Engine) + f.Placements, f.FreeGPUs = placementsFor(l, fleet, f.Engine) for _, pc := range f.Placements { if pc.Selected { f.Profile = pc.profile() @@ -152,15 +152,13 @@ func planServe(st mirror.Status, files []mirror.Status, base string, // A warning, not a refusal: an engine can be installed this afternoon, and // a node that reports nothing is an older agent rather than an empty one. if !runsAnywhere(fleet, f.Engine) { - f.Warning = "no node reports having " + f.Engine + " installed, and " + - extOf(st.File) + " weights need it" + f.Warning = l.T("serve.warn.not_installed", f.Engine, extOf(st.File)) if f.Engine != "llamacpp" && gpus(fleet) == 0 { - f.Warning += ". This fleet has no GPU either; a GGUF build of the " + - "same model runs on the CPU nodes" + f.Warning += ". " + l.T("serve.warn.no_gpu") } } f.CtxPool = defaultCtxPool - f.MinMemoryGB, f.MemoryBasis = memoryFor(st.BytesTotal, st.BytesDone), "weights plus half again plus two" + f.MinMemoryGB, f.MemoryBasis = memoryFor(st.BytesTotal, st.BytesDone), l.T("serve.basis.estimate") weights := st.BytesTotal if weights <= 0 { weights = st.BytesDone @@ -175,10 +173,10 @@ func planServe(st mirror.Status, files []mirror.Status, base string, // across the models on the live mirror at the same 16k — a spread the // flat "half again" could not see. f.MinMemoryGB = int((b + 1<<30 - 1) >> 30) - f.MemoryBasis = fmt.Sprintf("weights %.1f GB + KV cache %.1f GB for %d tokens (%d layers × %d KV heads) + %.1f GB for the engine", - float64(weights)/(1<<30), float64(int64(defaultCtxPool)*header.KVBytesPerToken())/(1<<30), - defaultCtxPool, header.BlockCount, kvHeads, - float64(int64(1<<30)+weights/20)/(1<<30)) + f.MemoryBasis = l.T("serve.basis.shape", + l.Num(float64(weights)/(1<<30), 1), l.Num(float64(int64(defaultCtxPool)*header.KVBytesPerToken())/(1<<30), 1), + l.Int(defaultCtxPool), header.BlockCount, kvHeads, + l.Num(float64(int64(1<<30)+weights/20)/(1<<30), 1)) } if fleet != nil { var cpus []int @@ -190,14 +188,15 @@ func planServe(st mirror.Status, files []mirror.Status, base string, f.CPUCores = catalog.SuggestCores(cpus) } if biggest := biggestNode(fleet); biggest > 0 && f.MinMemoryGB > biggest { - f.Warning = strings.TrimSuffix(f.Warning, ".") + - fmt.Sprintf("%sthe largest node has %d GB and this needs about %d", - sep(f.Warning), biggest, f.MinMemoryGB) + big := l.T("serve.warn.too_big", biggest, f.MinMemoryGB) + if f.Warning != "" { + big = l.T("serve.warn.and", strings.TrimSuffix(f.Warning, "."), big) + } + f.Warning = big } if cat != nil && cat.Declared(st.Model) { f.Already = true - f.Refusal = "a model called " + st.Model + - " is declared in configuration; edit it there rather than adding a second" + f.Refusal = l.T("serve.refuse.declared", st.Model) } return f } @@ -255,8 +254,9 @@ func gpus(fleet Source) int { return n } -// buildModel turns the submitted form into a catalogue entry. -func buildModel(get func(string) string) (catalog.Model, error) { +// buildModel turns the submitted form into a catalogue entry. Its refusals +// are in l, the language of whoever filled the form in. +func buildModel(l *Lang, get func(string) string) (catalog.Model, error) { m := catalog.Model{ Name: strings.TrimSpace(get("name")), Description: strings.TrimSpace(get("description")), @@ -264,7 +264,7 @@ func buildModel(get func(string) string) (catalog.Model, error) { RoutingProfile: "chat", } if m.Name == "" || m.Source == "" { - return m, errors.New("a model needs a name and a source") + return m, errors.New(l.T("serve.err.name_source")) } for _, a := range strings.Split(get("aliases"), ",") { if a = strings.TrimSpace(a); a != "" { @@ -272,18 +272,18 @@ func buildModel(get func(string) string) (catalog.Model, error) { } } - ctxLen, err := positive(get("context_length"), "the context length") + ctxLen, err := positive(l, get("context_length"), "serve.what.context_length") if err != nil { return m, err } m.ContextLength = ctxLen if v := strings.TrimSpace(get("max_output")); v != "" { - if m.MaxOutput, err = positive(v, "the output limit"); err != nil { + if m.MaxOutput, err = positive(l, v, "serve.what.max_output"); err != nil { return m, err } } - min, err := positive(get("replicas"), "the replica count") + min, err := positive(l, get("replicas"), "serve.what.replicas") if err != nil { return m, err } @@ -291,12 +291,12 @@ func buildModel(get func(string) string) (catalog.Model, error) { engine := strings.TrimSpace(get("engine")) if engine == "" { - return m, errors.New("which engine should run it?") + return m, errors.New(l.T("serve.err.engine")) } p := catalog.Profile{Engine: engine, Weight: 1, Priority: 100} gpus := 1 if v := strings.TrimSpace(get("gpus")); v != "" { - if gpus, err = positive(v, "the GPUs per replica"); err != nil { + if gpus, err = positive(l, v, "serve.what.gpus"); err != nil { return m, err } gpus = max(gpus, 1) @@ -316,11 +316,11 @@ func buildModel(get func(string) string) (catalog.Model, error) { place.GPUs = gpus case place.Value == "" || place.Value == "cpu": if engine != "llamacpp" { - return m, fmt.Errorf("%s runs on GPUs; choose a GPU class to place it on", engine) + return m, errors.New(l.T("serve.err.needs_gpu", engine)) } place.Value = "cpu" default: - return m, fmt.Errorf("%q is not a placement", place.Value) + return m, errors.New(l.T("serve.err.placement", place.Value)) } p.Name = place.profile() if v := strings.TrimSpace(get("profile")); v != "" { @@ -330,13 +330,13 @@ func buildModel(get func(string) string) (catalog.Model, error) { // parallel slots, so the number a client may use is smaller and is what // context_length above says. if v := strings.TrimSpace(get("ctx_size")); v != "" { - if _, err := positive(v, "the engine's context pool"); err != nil { + if _, err := positive(l, v, "serve.what.ctx_pool"); err != nil { return m, err } p.Args = []string{"--ctx-size", v} } if v := strings.TrimSpace(get("parallel")); v != "" && engine == "llamacpp" { - n, err := positive(v, "the parallel requests") + n, err := positive(l, v, "serve.what.parallel") if err != nil { return m, err } @@ -345,12 +345,12 @@ func buildModel(get func(string) string) (catalog.Model, error) { } } if v := strings.TrimSpace(get("min_memory_gb")); v != "" { - if p.MinMemoryGB, err = positive(v, "the memory requirement"); err != nil { + if p.MinMemoryGB, err = positive(l, v, "serve.what.memory"); err != nil { return m, err } } if v := strings.TrimSpace(get("cpu_cores")); v != "" { - if p.CPUCores, err = positive(v, "the cores"); err != nil { + if p.CPUCores, err = positive(l, v, "serve.what.cores"); err != nil { return m, err } } @@ -364,14 +364,22 @@ func buildModel(get func(string) string) (catalog.Model, error) { return m, nil } -func positive(v, what string) (int, error) { +// positive reads a whole number from a form field; what is the key naming +// the field, for the refusal. +func positive(l *Lang, v, what string) (int, error) { n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil || n < 0 { - return 0, fmt.Errorf("%s must be a whole number", what) + return 0, errors.New(l.T("serve.err.whole_number", l.T(what))) } return n, nil } +func init() { + registerKeys("serve.what.context_length", "serve.what.max_output", "serve.what.replicas", + "serve.what.gpus", "serve.what.ctx_pool", "serve.what.parallel", "serve.what.memory", + "serve.what.cores") +} + // memoryFor estimates the host RAM a model needs, from the size of its weights. // // Weights plus half again, plus two. The half covers the KV pool, which grows @@ -409,14 +417,6 @@ func biggestNode(fleet Source) int { return most } -// sep joins a second clause onto a warning that may be empty. -func sep(existing string) string { - if existing == "" { - return "" - } - return ", and " -} - // Run modes a page offers for a model, in the words a person would use. const ( modeAlways = "always" @@ -497,7 +497,7 @@ func (c placeChoice) profile() string { // on a cluster of GPUs, ran at a tenth of their speed, and nothing on the way // said why — the form had no choice to offer and named the profile "cpu". The // class selected is the one with the most cards free. -func placementsFor(fleet Source, engine string) ([]placeChoice, int) { +func placementsFor(l *Lang, fleet Source, engine string) ([]placeChoice, int) { var out []placeChoice free, best := 0, -1 if fleet != nil { @@ -506,7 +506,7 @@ func placementsFor(fleet Source, engine string) ([]placeChoice, int) { free += n out = append(out, placeChoice{ Value: "gpu:" + c.Class, - Label: fmt.Sprintf("GPU: %s, %d of %d free", c.Class, n, c.Total), + Label: l.T("serve.place.gpu", c.Class, n, c.Total), }) if best < 0 || n > fleet.Summary().GPUs[best].Free() { best = len(out) - 1 @@ -517,9 +517,9 @@ func placementsFor(fleet Source, engine string) ([]placeChoice, int) { out[best].Selected = true } if engine == "llamacpp" { - label := "CPU only: no GPU, many times slower" + label := l.T("serve.place.cpu_slower") if len(out) == 0 { - label = "CPU: this fleet has no GPU" + label = l.T("serve.place.cpu_only") } out = append(out, placeChoice{Value: "cpu", Label: label, Selected: best < 0}) } @@ -539,8 +539,9 @@ func freeGPUs(fleet Source) int { } // describeServe says, in one line, what a model will run as: the summary -// shown before it is added and in the message after. -func describeServe(m catalog.Model) string { +// shown before it is added and in the message after. The form's script says +// the same with the same sentences, so the two cannot drift apart in wording. +func describeServe(l *Lang, m catalog.Model) string { if len(m.Profiles) == 0 { return "" } @@ -549,27 +550,27 @@ func describeServe(m catalog.Model) string { if engine == "" { engine = p.Engine } - where := "on the CPU" + where := l.T("serve.on_cpu", engine) if p.GPUClass != "" { n := max(p.GPUsPerNode, p.TP, 1) - where = fmt.Sprintf("on %s %s", plural(n, "GPU", "GPUs"), p.GPUClass) + where = l.T("serve.on_gpu_one", engine, p.GPUClass) if n > 1 { - where = fmt.Sprintf("on %d %s GPUs of one node", n, p.GPUClass) + where = l.T("serve.on_gpus", engine, n, p.GPUClass) } } - parts := []string{engine + " " + where} + parts := []string{where} if p.MinMemoryGB > 0 { - parts = append(parts, fmt.Sprintf("%d GB of host memory", p.MinMemoryGB)) + parts = append(parts, l.T("serve.host_memory", l.Int(int64(p.MinMemoryGB)))) } if p.GPUClass == "" && p.CPUCores > 0 { - parts = append(parts, plural(p.CPUCores, "core", "cores")) + parts = append(parts, l.N("serve.cores", int64(p.CPUCores))) } pool, parallel := argInt(p.Args, "--ctx-size"), argInt(p.Args, "--parallel") switch { case pool > 0 && parallel > 0: - parts = append(parts, fmt.Sprintf("context pool %d over %d parallel requests (%d each)", pool, parallel, pool/parallel)) + parts = append(parts, l.T("serve.pool_parallel", l.Int(int64(pool)), l.Int(int64(parallel)), l.Int(int64(pool/parallel)))) case pool > 0: - parts = append(parts, fmt.Sprintf("context pool %d", pool)) + parts = append(parts, l.T("serve.pool", l.Int(int64(pool)))) } return strings.Join(parts, ", ") } diff --git a/internal/web/admin_serve_test.go b/internal/web/admin_serve_test.go index 91d7049..80acfff 100644 --- a/internal/web/admin_serve_test.go +++ b/internal/web/admin_serve_test.go @@ -492,7 +492,7 @@ func TestTheChosenPlacementDecidesWhereItRuns(t *testing.T) { "placement": {place}, "gpus": {gpus}, "min_memory_gb": {"8"}, } } - m, err := buildModel(form("llamacpp", "gpu:t4", "4").Get) + m, err := buildModel(english, form("llamacpp", "gpu:t4", "4").Get) if err != nil { t.Fatal(err) } @@ -500,17 +500,17 @@ func TestTheChosenPlacementDecidesWhereItRuns(t *testing.T) { if p.GPUClass != "t4" || p.GPUsPerNode != 4 || p.TP > 1 || p.Name != "t4-x4" { t.Errorf("llama.cpp on four t4s: %+v", p) } - if got := describeServe(m); got != "llama.cpp on 4 t4 GPUs of one node, 8 GB of host memory, "+ + if got := describeServe(english, m); got != "llama.cpp on 4 t4 GPUs of one node, 8 GB of host memory, "+ "context pool 16384 over 4 parallel requests (4096 each)" { t.Errorf("summary %q", got) } - if m, _ := buildModel(form("vllm", "gpu:t4", "2").Get); m.Profiles[0].TP != 2 || m.Profiles[0].GPUsPerNode != 0 { + if m, _ := buildModel(english, form("vllm", "gpu:t4", "2").Get); m.Profiles[0].TP != 2 || m.Profiles[0].GPUsPerNode != 0 { t.Errorf("vLLM's GPU count is tp: %+v", m.Profiles[0]) } - if m, _ := buildModel(form("llamacpp", "cpu", "1").Get); m.Profiles[0].GPUClass != "" || m.Profiles[0].Name != "cpu" { + if m, _ := buildModel(english, form("llamacpp", "cpu", "1").Get); m.Profiles[0].GPUClass != "" || m.Profiles[0].Name != "cpu" { t.Errorf("the CPU: %+v", m.Profiles[0]) } - if _, err := buildModel(form("vllm", "cpu", "1").Get); err == nil { + if _, err := buildModel(english, form("vllm", "cpu", "1").Get); err == nil { t.Error("vLLM on the CPU was accepted") } } diff --git a/internal/web/admin_usage.go b/internal/web/admin_usage.go index ebcf0a7..5a00a1e 100644 --- a/internal/web/admin_usage.go +++ b/internal/web/admin_usage.go @@ -2,7 +2,6 @@ package web import ( "context" - "fmt" "net/http" "sort" "time" @@ -26,7 +25,10 @@ type StatsReader interface { // pctile is a percentile from the usage histograms as a person reads it: // "180 ms", "4.2 s", "> 120 s" past the last bucket, "—" with nothing to read. -func pctile(q store.Quantiles, which int) string { +func pctile(q store.Quantiles, which int) string { return english.pctile(q, which) } + +// pctile is the package's pctile in this language's numbers. +func (l *Lang) pctile(q store.Quantiles, which int) string { if q.N == 0 { return "—" } @@ -37,13 +39,13 @@ func pctile(q store.Quantiles, which int) string { case 99: v = q.P99 if q.Over { - return fmt.Sprintf("> %d s", v/1000) + return "> " + l.T("unit.s", l.Int(v/1000)) } } if v < 1000 { - return fmt.Sprintf("%d ms", v) + return l.T("unit.ms", l.Int(v)) } - return fmt.Sprintf("%.1f s", float64(v)/1000) + return l.T("unit.s", l.Num(float64(v)/1000, 1)) } // usageWindow is one length of history the page offers, and the slice it is cut @@ -53,17 +55,38 @@ func pctile(q store.Quantiles, which int) string { // five-minute slices is 288 points across 720 pixels, which is a texture; a // month in five-minute slices is 8,640, which is a smear. Roughly fifty to a // hundred points is what a line of this size can actually show. +// +// Its name on the page is the catalogue's usage.window.KEY. type usageWindow struct { Key string - Label string Span time.Duration Width time.Duration } var usageWindows = []usageWindow{ - {"24h", "last 24 hours", 24 * time.Hour, 20 * time.Minute}, - {"7d", "last 7 days", 7 * 24 * time.Hour, 2 * time.Hour}, - {"30d", "last 30 days", 30 * 24 * time.Hour, 12 * time.Hour}, + {"24h", 24 * time.Hour, 20 * time.Minute}, + {"7d", 7 * 24 * time.Hour, 2 * time.Hour}, + {"30d", 30 * 24 * time.Hour, 12 * time.Hour}, +} + +func init() { + registerKeys("usage.window.24h", "usage.window.7d", "usage.window.30d", + "usage.group.org", "usage.group.team", "usage.group.project", "usage.group.user", "usage.group.model") +} + +// windowChoices are the windows as the chooser offers them, in l. +func windowChoices(l *Lang, selected string) []windowChoice { + out := make([]windowChoice, 0, len(usageWindows)) + for _, uw := range usageWindows { + out = append(out, windowChoice{uw.Key, l.T("usage.window." + uw.Key), uw.Key == selected}) + } + return out +} + +// windowChoice is one window in the chooser. +type windowChoice struct { + Key, Label string + Selected bool } func windowFor(key string) usageWindow { @@ -99,12 +122,9 @@ type usageRow struct { type adminUsageData struct { Nav Nav Groups []usageGroup - Windows []struct { - Key, Label string - Selected bool - } - By string - Window string + Windows []windowChoice + By string + Window string Requests TimeSeries Tokens TimeSeries @@ -150,6 +170,7 @@ func (p *AccountPages) buildUsage(r *http.Request, s UsageAdmin, priced Priced) by = store.GroupByTeam } win := windowFor(r.URL.Query().Get("window")) + l := langOf(r) d := adminUsageData{ Nav: navFrom(r, "usage"), @@ -160,24 +181,19 @@ func (p *AccountPages) buildUsage(r *http.Request, s UsageAdmin, priced Priced) } for _, g := range store.Groupings() { d.Groups = append(d.Groups, usageGroup{ - Key: string(g), Label: groupLabel(g), Selected: g == by, + Key: string(g), Label: groupLabel(l, g), Selected: g == by, }) } - for _, uw := range usageWindows { - d.Windows = append(d.Windows, struct { - Key, Label string - Selected bool - }{uw.Key, uw.Label, uw.Key == win.Key}) - } + d.Windows = windowChoices(l, win.Key) rows, err := s.UsageBucketsBy(r.Context(), by, time.Now().Add(-win.Span), win.Width) if err != nil { p.log.Error("could not read usage over time", "by", by, "err", err) - d.Error = "could not read usage" + d.Error = l.T("usage.err.read") return d } - d.Requests, d.Tokens, d.Spend, d.Rows = usageCharts(rows) + d.Requests, d.Tokens, d.Spend, d.Rows = usageChartsIn(l, rows) if sr, ok := s.(StatsReader); ok { stats, err := sr.StatsBy(r.Context(), by, time.Now().Add(-win.Span)) if err != nil { @@ -188,7 +204,7 @@ func (p *AccountPages) buildUsage(r *http.Request, s UsageAdmin, priced Priced) } d.HasStats = stats != nil } - d.GPUHeld, d.GPUHours = p.gpuHours(r.Context(), win) + d.GPUHeld, d.GPUHours = p.gpuHours(r.Context(), l, win) return d } @@ -205,7 +221,7 @@ type gpuHoursRow struct { // holds is placement's, not the request log's: a model nobody asks still // holds its cards. As far back as the store keeps history, which for a // default Prometheus is fifteen days. -func (p *AccountPages) gpuHours(ctx context.Context, win usageWindow) (TimeSeries, []gpuHoursRow) { +func (p *AccountPages) gpuHours(ctx context.Context, l *Lang, win usageWindow) (TimeSeries, []gpuHoursRow) { if p.history == nil { return TimeSeries{}, nil } @@ -216,7 +232,7 @@ func (p *AccountPages) gpuHours(ctx context.Context, win usageWindow) (TimeSerie var rows []gpuHoursRow for i := range series { if series[i].Name == "" { - series[i].Name = "no owner" + series[i].Name = l.T("usage.no_owner") } row := gpuHoursRow{Owner: series[i].Name, Now: series[i].Last()} // Each point is the cards held at that instant, standing for the @@ -225,7 +241,7 @@ func (p *AccountPages) gpuHours(ctx context.Context, win usageWindow) (TimeSerie rows = append(rows, row) } sort.Slice(rows, func(i, j int) bool { return rows[i].Hours > rows[j].Hours }) - return LineChart(series, "", 6), rows + return l.LineChart(series, "", 6), rows } // seriesOf turns each group's buckets into a line of one measure. @@ -233,10 +249,10 @@ func (p *AccountPages) gpuHours(ctx context.Context, win usageWindow) (TimeSerie // One pass per measure rather than one query per measure: the buckets are // already in memory and already share a clock, and three queries that could // disagree about the window is a worse answer than three loops. -func seriesOf(rows []store.UsageSeries, value func(store.Bucket) float64) []promq.Series { +func seriesOf(l *Lang, rows []store.UsageSeries, value func(store.Bucket) float64) []promq.Series { out := make([]promq.Series, 0, len(rows)) for _, u := range rows { - s := promq.Series{Name: displayName(u)} + s := promq.Series{Name: displayName(l, u)} for _, b := range u.Buckets { s.Points = append(s.Points, promq.Point{At: b.Start, Value: value(b)}) } @@ -250,45 +266,44 @@ func seriesOf(rows []store.UsageSeries, value func(store.Bucket) float64) []prom // A person with no account is named rather than left blank: keys issued before // accounts existed carry real traffic, and an unlabelled line in a legend is // the one nobody can act on. -func displayName(u store.UsageSeries) string { +func displayName(l *Lang, u store.UsageSeries) string { if u.Name != "" { return u.Name } if u.Grouping == store.GroupByUser { - return "keys with no owner" + return l.T("usage.no_owner_keys") } if u.ID != "" { return u.ID } - return "unknown" + return l.T("usage.unknown") } -func groupLabel(g store.Grouping) string { +// groupLabel names a grouping in l. +func groupLabel(l *Lang, g store.Grouping) string { switch g { - case store.GroupByOrg: - return "organization" - case store.GroupByTeam: - return "team" - case store.GroupByProject: - return "project" - case store.GroupByUser: - return "person" - case store.GroupByModel: - return "model" + case store.GroupByOrg, store.GroupByTeam, store.GroupByProject, store.GroupByUser, store.GroupByModel: + return l.T("usage.group." + string(g)) default: return string(g) } } -// usageCharts draws usage over time three ways, with the table beneath them. +// usageCharts is usageChartsIn in English. func usageCharts(rows []store.UsageSeries) (req, tok, spend TimeSeries, table []usageRow) { - req = LineChartTotals(seriesOf(rows, func(b store.Bucket) float64 { + return usageChartsIn(english, rows) +} + +// usageChartsIn draws usage over time three ways, with the table beneath +// them, the names and figures in l. +func usageChartsIn(l *Lang, rows []store.UsageSeries) (req, tok, spend TimeSeries, table []usageRow) { + req = l.LineChartTotals(seriesOf(l, rows, func(b store.Bucket) float64 { return float64(b.Requests) }), "", 4) - tok = LineChartTotals(seriesOf(rows, func(b store.Bucket) float64 { + tok = l.LineChartTotals(seriesOf(l, rows, func(b store.Bucket) float64 { return float64(b.Prompt + b.Completion) }), "", 4) - spend = LineChartTotals(seriesOf(rows, func(b store.Bucket) float64 { + spend = l.LineChartTotals(seriesOf(l, rows, func(b store.Bucket) float64 { // Shown in whole units, as every other spend figure is: a budget is // written in units and a chart in micros would not compare with it. return float64(b.CostMicros) / 1e6 @@ -296,10 +311,10 @@ func usageCharts(rows []store.UsageSeries) (req, tok, spend TimeSeries, table [] for _, u := range rows { table = append(table, usageRow{ ID: u.ID, - Name: displayName(u), + Name: displayName(l, u), Requests: u.Requests, Tokens: u.Tokens(), - Spend: unitsOf(u.CostMicros), + Spend: l.Units(u.CostMicros), Errors: u.Errors, }) } diff --git a/internal/web/admin_users.go b/internal/web/admin_users.go index f87673f..d27eeef 100644 --- a/internal/web/admin_users.go +++ b/internal/web/admin_users.go @@ -3,7 +3,6 @@ package web import ( "context" "errors" - "fmt" "net/http" "sort" "strings" @@ -124,144 +123,146 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) { })) mux.HandleFunc("POST /admin/users/{id}/approve", p.adminAction(s, - func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error) { + func(ctx context.Context, admin store.User, id string, r *http.Request) (notice, error) { project := r.FormValue("project") if err := s.ApproveUser(ctx, id, project, admin.Email, time.Now()); err != nil { - return "", err + return notice{}, err } if project == "" { - return "approved, on their own for now", nil + return say("users.msg.approved_alone"), nil } - return "approved into " + projectPath(ctx, s, project), nil + return say("users.msg.approved_into", projectPath(ctx, s, project)), nil })) mux.HandleFunc("POST /admin/users/{id}/suspend", p.adminAction(s, - func(ctx context.Context, admin store.User, id string, _ *http.Request) (string, error) { + func(ctx context.Context, admin store.User, id string, _ *http.Request) (notice, error) { if id == admin.ID { // Locking yourself out is not a thing an interface should let // you do by misclicking. - return "", errors.New("you cannot suspend your own account") + return notice{}, refuse("users.err.self_suspend") } if err := s.SetUserState(ctx, id, store.UserSuspended); err != nil { - return "", err + return notice{}, err } // Otherwise they stay signed in and suspension is advisory until // their session happens to expire. if err := s.DeleteUserSessions(ctx, id); err != nil { - return "", err + return notice{}, err } - return "suspended, and signed out everywhere", nil + return say("users.msg.suspended"), nil })) mux.HandleFunc("POST /admin/users/{id}/reinstate", p.adminAction(s, - func(ctx context.Context, _ store.User, id string, _ *http.Request) (string, error) { + func(ctx context.Context, _ store.User, id string, _ *http.Request) (notice, error) { if err := s.SetUserState(ctx, id, store.UserActive); err != nil { - return "", err + return notice{}, err } - return "reinstated", nil + return say("users.msg.reinstated"), nil })) mux.HandleFunc("POST /admin/users/{id}/join", p.adminAction(s, - func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error) { + func(ctx context.Context, admin store.User, id string, r *http.Request) (notice, error) { scope, scopeID, ok := strings.Cut(r.FormValue("scope"), ":") if !ok || scopeID == "" { - return "", errors.New("choose where they go") + return notice{}, refuse("users.err.choose_where") } if err := s.AddMembership(ctx, id, store.Scope(scope), scopeID, store.RoleMember, admin.Email, time.Now()); err != nil { - return "", err + return notice{}, err } - return "added to " + scopeLabel(ctx, s, store.Scope(scope), scopeID), nil + return say("users.msg.added_to", scopeLabel(ctx, s, store.Scope(scope), scopeID)), nil })) // Managing is a role on an organization membership: it lets the person see // that organization's page, and nothing else changes. mux.HandleFunc("POST /admin/users/{id}/role", p.adminAction(s, - func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error) { + func(ctx context.Context, admin store.User, id string, r *http.Request) (notice, error) { scope, scopeID, ok := strings.Cut(r.FormValue("scope"), ":") role := r.FormValue("role") if !ok || store.Scope(scope) != store.ScopeOrg || (role != store.RoleManager && role != store.RoleMember) { - return "", errors.New("a manager is a role in an organization") + return notice{}, refuse("users.err.manager_role") } if err := s.AddMembership(ctx, id, store.ScopeOrg, scopeID, role, admin.Email, time.Now()); err != nil { - return "", err + return notice{}, err } label := scopeLabel(ctx, s, store.ScopeOrg, scopeID) if role == store.RoleManager { - return "now manages " + label, nil + return say("users.msg.now_manages", label), nil } - return "no longer manages " + label, nil + return say("users.msg.no_longer_manages", label), nil })) mux.HandleFunc("POST /admin/users/{id}/leave", p.adminAction(s, - func(ctx context.Context, _ store.User, id string, r *http.Request) (string, error) { + func(ctx context.Context, _ store.User, id string, r *http.Request) (notice, error) { scope, scopeID, ok := strings.Cut(r.FormValue("scope"), ":") if !ok { - return "", errors.New("nothing to leave") + return notice{}, refuse("users.err.nothing_to_leave") } label := scopeLabel(ctx, s, store.Scope(scope), scopeID) revoked, err := s.RemoveMembership(ctx, id, store.Scope(scope), scopeID, time.Now()) if err != nil { - return "", err + return notice{}, err } - msg := "removed from " + label - if revoked > 0 { - msg += "; " + plural(revoked, "key", "keys") + " they made there revoked" + switch { + case revoked == 1: + return say("users.msg.removed_from_one_key", label), nil + case revoked > 1: + return say("users.msg.removed_from_keys", label, revoked), nil } - return msg, nil + return say("users.msg.removed_from", label), nil })) mux.HandleFunc("POST /admin/users/{id}/delete", p.adminAction(s, - func(ctx context.Context, admin store.User, id string, _ *http.Request) (string, error) { + func(ctx context.Context, admin store.User, id string, _ *http.Request) (notice, error) { if id == admin.ID { - return "", errors.New("you cannot remove your own account") + return notice{}, refuse("users.err.self_remove") } if err := s.DeleteUser(ctx, id, time.Now()); err != nil { - return "", err + return notice{}, err } - return "removed; their keys are revoked", nil + return say("users.msg.removed_keys_revoked"), nil })) // The tenancy tree. Creating a container is not granting access: nobody can // use one until an account is approved into it. mux.HandleFunc("POST /admin/tenancy/org", p.tenancyAction(s, - func(ctx context.Context, r *http.Request) (string, error) { + func(ctx context.Context, r *http.Request) (notice, error) { _, err := s.CreateOrg(ctx, r.FormValue("name")) if err != nil { - return "", err + return notice{}, err } - return "organization " + r.FormValue("name") + " created", nil + return say("users.msg.org_created", r.FormValue("name")), nil })) mux.HandleFunc("POST /admin/tenancy/team", p.tenancyAction(s, - func(ctx context.Context, r *http.Request) (string, error) { + func(ctx context.Context, r *http.Request) (notice, error) { org := r.FormValue("org") if org == "" { - return "", errors.New("choose the organization this team belongs to") + return notice{}, refuse("users.err.choose_org") } _, err := s.CreateTeam(ctx, org, r.FormValue("name")) if err != nil { - return "", err + return notice{}, err } - return "team " + r.FormValue("name") + " created", nil + return say("users.msg.team_created", r.FormValue("name")), nil })) mux.HandleFunc("POST /admin/tenancy/project", p.tenancyAction(s, - func(ctx context.Context, r *http.Request) (string, error) { + func(ctx context.Context, r *http.Request) (notice, error) { team := r.FormValue("team") if team == "" { - return "", errors.New("choose the team this project belongs to") + return notice{}, refuse("users.err.choose_team") } id, err := s.CreateProject(ctx, team, r.FormValue("name")) if err != nil { - return "", err + return notice{}, err } r.Form.Set("open", id) - return "project " + r.FormValue("name") + " created", nil + return say("users.msg.project_created", r.FormValue("name")), nil })) mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/rename", p.tenancyAction(s, - func(ctx context.Context, r *http.Request) (string, error) { + func(ctx context.Context, r *http.Request) (notice, error) { id, name := r.PathValue("id"), strings.TrimSpace(r.FormValue("name")) var err error switch r.PathValue("kind") { @@ -272,37 +273,37 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) { case "project": err = s.RenameProject(ctx, id, name) default: - return "", errors.New("nothing of that kind") + return notice{}, refuse("users.err.no_such_kind") } if err != nil { - return "", err + return notice{}, err } - return "renamed to " + name, nil + return say("users.msg.renamed", name), nil })) mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/move", p.tenancyAction(s, - func(ctx context.Context, r *http.Request) (string, error) { + func(ctx context.Context, r *http.Request) (notice, error) { id, to := r.PathValue("id"), r.FormValue("to") if to == "" { - return "", errors.New("choose where it goes") + return notice{}, refuse("users.err.choose_destination") } switch r.PathValue("kind") { case "team": if err := s.MoveTeam(ctx, id, to); err != nil { - return "", err + return notice{}, err } - return "team moved", nil + return say("users.msg.team_moved"), nil case "project": if err := s.MoveProject(ctx, id, to); err != nil { - return "", err + return notice{}, err } - return "project moved to " + projectPath(ctx, s, id), nil + return say("users.msg.project_moved", projectPath(ctx, s, id)), nil } - return "", errors.New("only a team or a project moves") + return notice{}, refuse("users.err.only_team_project_moves") })) mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/delete", p.tenancyAction(s, - func(ctx context.Context, r *http.Request) (string, error) { + func(ctx context.Context, r *http.Request) (notice, error) { id := r.PathValue("id") var err error switch r.PathValue("kind") { @@ -313,43 +314,103 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) { case "project": err = s.DeleteProject(ctx, id) default: - return "", errors.New("nothing of that kind") + return notice{}, refuse("users.err.no_such_kind") } if err != nil { - return "", err + return notice{}, err } r.Form.Del("open") - return "removed", nil + return say("users.msg.removed"), nil })) // The same as joining, from the scope's side: a form on a project, team or // organization that names the person. mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/add", p.tenancyAction(s, - func(ctx context.Context, r *http.Request) (string, error) { + func(ctx context.Context, r *http.Request) (notice, error) { user := r.FormValue("user") if user == "" { - return "", errors.New("choose who to add") + return notice{}, refuse("users.err.choose_who") } scope, id := store.Scope(r.PathValue("kind")), r.PathValue("id") if err := s.AddMembership(ctx, user, scope, id, store.RoleMember, "", time.Now()); err != nil { - return "", err + return notice{}, err } who := user if u, err := s.UserByID(ctx, user); err == nil { who = u.Email } - return who + " added to " + scopeLabel(ctx, s, scope, id), nil + return say("users.msg.member_added", who, scopeLabel(ctx, s, scope, id)), nil })) mux.HandleFunc("POST /admin/keys/{id}/revoke", p.tenancyAction(s, - func(ctx context.Context, r *http.Request) (string, error) { + func(ctx context.Context, r *http.Request) (notice, error) { if err := s.RevokeKey(ctx, r.PathValue("id"), time.Now()); err != nil { - return "", err + return notice{}, err } - return "key revoked; a running server stops accepting it when its cache expires", nil + return say("users.msg.key_revoked"), nil })) } +// notice is what the page says about a change, kept as a catalogue key and +// its arguments rather than as text: the log and the audit record take it in +// English, which is what they are searched in, and the page in the language +// of the person who made the change. +type notice struct { + key string + args []any +} + +func say(key string, args ...any) notice { return notice{key: key, args: args} } + +// in is the notice in one language. +func (n notice) in(l *Lang) string { + if n.key == "" { + return "" + } + return l.T(n.key, n.args...) +} + +// refusal is a change the page refused, said as a notice so the person who +// asked reads why in their own language. As an error it is English, for the +// log and the audit record. +type refusal struct{ notice } + +func (e refusal) Error() string { return e.in(english) } + +func refuse(key string, args ...any) error { return refusal{say(key, args...)} } + +// errorIn is an error from a change as the page shows it. +func errorIn(l *Lang, err error) string { + var r refusal + if errors.As(err, &r) { + return r.in(l) + } + return l.Err(err) +} + +// The notices are built by key in the handlers above; the catalogue test +// cannot see a key passed to say, so they are listed here. +func init() { + registerKeys( + "users.msg.approved_alone", "users.msg.approved_into", "users.msg.suspended", + "users.msg.reinstated", "users.msg.added_to", "users.msg.now_manages", + "users.msg.no_longer_manages", "users.msg.removed_from", "users.msg.removed_from_one_key", + "users.msg.removed_from_keys", "users.msg.removed_keys_revoked", "users.msg.org_created", + "users.msg.team_created", "users.msg.project_created", "users.msg.renamed", + "users.msg.team_moved", "users.msg.project_moved", "users.msg.removed", + "users.msg.member_added", "users.msg.key_revoked", + "users.err.self_suspend", "users.err.choose_where", "users.err.manager_role", + "users.err.nothing_to_leave", "users.err.self_remove", "users.err.choose_org", + "users.err.choose_team", "users.err.no_such_kind", "users.err.choose_destination", + "users.err.only_team_project_moves", "users.err.choose_who", "users.err.duplicate", + "users.err.not_empty", "users.err.gone", + // The words for an account's and a key's state, looked up by the + // state, which is also the row's class. + "users.state.active", "users.state.pending", "users.state.suspended", + "users.key_state.active", "users.key_state.revoked", "users.key_state.expired", + ) +} + func projectPath(ctx context.Context, s AccountAdmin, id string) string { all, err := s.AllProjects(ctx) if err == nil { @@ -388,14 +449,17 @@ func scopeLabel(ctx context.Context, s AccountAdmin, scope store.Scope, id strin } // plainError says what a store error means to the person who caused it. +// +// What a scope still holds when it is not empty comes from the store in +// English ("it still has 1 member and 2 keys"), and is shown as it is. func plainError(err error) error { switch { case errors.Is(err, store.ErrDuplicateName): - return errors.New("there is already one of those here") + return refuse("users.err.duplicate") case errors.Is(err, store.ErrNotEmpty): - return fmt.Errorf("not removed: %s", strings.TrimPrefix(err.Error(), store.ErrNotEmpty.Error()+": ")) + return refuse("users.err.not_empty", strings.TrimPrefix(err.Error(), store.ErrNotEmpty.Error()+": ")) case errors.Is(err, store.ErrNotFound): - return errors.New("that is no longer there; the page was out of date") + return refuse("users.err.gone") } return err } @@ -407,7 +471,7 @@ func plainError(err error) error { // here, and passing an empty id through it would write audit lines about // nobody. func (p *AccountPages) tenancyAction(s AccountAdmin, - do func(ctx context.Context, r *http.Request) (string, error), + do func(ctx context.Context, r *http.Request) (notice, error), ) http.HandlerFunc { return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) @@ -415,19 +479,20 @@ func (p *AccountPages) tenancyAction(s AccountAdmin, http.Error(w, "bad request", http.StatusBadRequest) return } + l := langOf(r) msg, err := do(r.Context(), r) if err != nil { err = plainError(err) p.log.Warn("a tenancy change was refused", "admin", admin.Email, "path", r.URL.Path, "err", err) noteAudit(r, "", "", err.Error()) - p.renderUsers(w, r, s, admin, "", err.Error(), r.FormValue("open")) + p.renderUsers(w, r, s, admin, "", errorIn(l, err), r.FormValue("open")) return } p.log.Info("the tenancy tree was changed", "admin", admin.Email, - "path", r.URL.Path, "what", msg) - noteAudit(r, "", msg, "") - p.renderUsers(w, r, s, admin, msg, "", r.FormValue("open")) + "path", r.URL.Path, "what", msg.in(english)) + noteAudit(r, "", msg.in(english), "") + p.renderUsers(w, r, s, admin, msg.in(l), "", r.FormValue("open")) }) } @@ -454,7 +519,7 @@ func (p *AccountPages) adminOnly( // adminAction wraps a change to one account: admin session, CSRF, audit line, // render. func (p *AccountPages) adminAction(s AccountAdmin, - do func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error), + do func(ctx context.Context, admin store.User, id string, r *http.Request) (notice, error), ) http.HandlerFunc { return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) @@ -463,6 +528,7 @@ func (p *AccountPages) adminAction(s AccountAdmin, return } id := r.PathValue("id") + l := langOf(r) subject := id if u, err := s.UserByID(r.Context(), id); err == nil { @@ -477,13 +543,13 @@ func (p *AccountPages) adminAction(s AccountAdmin, p.log.Warn("an administrative action was refused", "admin", admin.Email, "subject", subject, "err", err) noteAudit(r, subject, "", err.Error()) - p.renderUsers(w, r, s, admin, "", subject+": "+err.Error(), r.FormValue("open")) + p.renderUsers(w, r, s, admin, "", l.T("users.err.about", subject, errorIn(l, err)), r.FormValue("open")) return } p.log.Info("an account was changed", - "admin", admin.Email, "subject", subject, "what", msg) - noteAudit(r, subject, msg, "") - p.renderUsers(w, r, s, admin, subject+" "+msg, "", r.FormValue("open")) + "admin", admin.Email, "subject", subject, "what", msg.in(english)) + noteAudit(r, subject, msg.in(english), "") + p.renderUsers(w, r, s, admin, l.T("users.msg.about", subject, msg.in(l)), "", r.FormValue("open")) }) } @@ -495,7 +561,7 @@ func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request, // buildUsers is the tree and its people, as the first tab shows them. func (p *AccountPages) buildUsers(r *http.Request, s AccountAdmin, admin store.User, msg, errMsg, open string) adminUsersData { - ctx := r.Context() + ctx, l := r.Context(), langOf(r) d := adminUsersData{ Nav: navFrom(r, "people"), Message: msg, Error: errMsg, Open: open, AdminID: admin.ID, Sel: r.FormValue("sel"), @@ -508,26 +574,28 @@ func (p *AccountPages) buildUsers(r *http.Request, s AccountAdmin, admin store.U Keys: map[string][]keyRow{}, } var err error - logErr := func(what string, err error) { + // logErr logs what could not be read in English and says so on the page, + // once, in the reader's language. + logErr := func(what, said string, err error) { if err != nil { p.log.Error("could not read "+what, "err", err) if d.Error == "" { - d.Error = "could not read " + what + d.Error = said } } } d.Projects, err = s.AllProjects(ctx) - logErr("the projects", err) + logErr("the projects", l.T("users.err.read_projects"), err) paths := make(map[string]string, len(d.Projects)) for _, pp := range d.Projects { paths[pp.ID] = pp.Path } all, err := s.Users(ctx, "") - logErr("the accounts", err) + logErr("the accounts", l.T("users.err.read_accounts"), err) memberships, err := s.AllMemberships(ctx) - logErr("the memberships", err) + logErr("the memberships", l.T("users.err.read_memberships"), err) byUser := map[string][]store.Membership{} for _, m := range memberships { byUser[m.UserID] = append(byUser[m.UserID], m) @@ -549,18 +617,18 @@ func (p *AccountPages) buildUsers(r *http.Request, s AccountAdmin, admin store.U } keys, err := s.AllKeys(ctx) - logErr("the keys", err) + logErr("the keys", l.T("users.err.read_keys"), err) now := time.Now() for _, k := range keys { d.Keys[k.ProjectID] = append(d.Keys[k.ProjectID], keyRow{KeyRow: k, State: keyState(k.Key, now)}) } d.Tree, err = s.Tree(ctx) - logErr("the tree", err) + logErr("the tree", l.T("users.err.read_tree"), err) d.Orgs, err = s.AllOrgs(ctx) - logErr("the organizations", err) + logErr("the organizations", l.T("users.err.read_orgs"), err) d.Teams, err = s.AllTeams(ctx) - logErr("the teams", err) + logErr("the teams", l.T("users.err.read_teams"), err) for _, o := range d.Orgs { d.Scopes = append(d.Scopes, scopeOption{"org:" + o.ID, o.Name}) } @@ -574,6 +642,8 @@ func (p *AccountPages) buildUsers(r *http.Request, s AccountAdmin, admin store.U return d } +// keyState is a key's state as a value: the row's class, and the key its word +// on the page is looked up by. func keyState(k store.Key, now time.Time) string { switch { case !k.RevokedAt.IsZero(): diff --git a/internal/web/chart.go b/internal/web/chart.go index 386431f..a1ee941 100644 --- a/internal/web/chart.go +++ b/internal/web/chart.go @@ -3,7 +3,6 @@ package web import ( "fmt" "html/template" - "strconv" "strings" "time" ) @@ -39,7 +38,14 @@ const ( // Empty and all-zero series render as an empty frame rather than nothing: a // missing chart reads as a broken page, and a flat one reads as no traffic, // which is the truth. -func BarChart(s Series, unit string) template.HTML { +// +// unit is written after the peak as it is given, so a caller passes it in +// the page's language. +func BarChart(s Series, unit string) template.HTML { return english.BarChart(s, unit) } + +// BarChart is the chart in this language: its peak, and the words it says +// when there is none. +func (l *Lang) BarChart(s Series, unit string) template.HTML { if s.Colour == "" { s.Colour = "--accent" } @@ -80,9 +86,9 @@ func BarChart(s Series, unit string) template.HTML { } b.WriteString(``) - peak := fmt.Sprintf("peak %s %s", compact(maxV), unit) + peak := l.T("chart.peak_unit", l.Compact(maxV), unit) if maxV == 0 { - peak = "nothing recorded" + peak = l.T("chart.nothing_recorded") } return template.HTML(`
` + b.String() + //nolint:gosec // built above `` + string(escapeText(peak)) + `
`) @@ -111,28 +117,13 @@ func escapeText(s string) template.HTML { } // compact renders a number the way a person reads one at a glance. -func compact(v float64) string { - switch { - case v >= 1e9: - return fmt.Sprintf("%.1fB", v/1e9) - case v >= 1e6: - return fmt.Sprintf("%.1fM", v/1e6) - case v >= 1e3: - return fmt.Sprintf("%.1fk", v/1e3) - case v == float64(int64(v)): - return fmt.Sprintf("%d", int64(v)) - case v > 0 && v < 1: - // Two significant digits rather than one decimal place. A spend of - // 0.03 printed as "0.0" says nothing was spent, which is the one thing - // a figure must not say when something was. Counts never reach this - // branch: a whole number is caught above, and zero is whole. - return strconv.FormatFloat(v, 'g', 2, 64) - default: - return fmt.Sprintf("%.1f", v) - } -} +func compact(v float64) string { return english.Compact(v) } // hourLabel is what a bar's tooltip says. -func hourLabel(t time.Time, v float64, unit string) string { - return fmt.Sprintf("%s — %s %s", t.Local().Format("Mon 15:04"), compact(v), unit) +func hourLabel(t time.Time, v float64, unit string) string { return english.hourLabel(t, v, unit) } + +// hourLabel names the day the way this language does: "Mon 15:04", "Pzt +// 15:04". unit is the caller's, in this language already. +func (l *Lang) hourLabel(t time.Time, v float64, unit string) string { + return l.T("chart.hour_label", l.Format(t.Local(), "Mon 15:04"), l.Compact(v), unit) } diff --git a/internal/web/charts2.go b/internal/web/charts2.go index 3f91820..5b09ae0 100644 --- a/internal/web/charts2.go +++ b/internal/web/charts2.go @@ -26,11 +26,17 @@ type Row struct { // end of each bar rather than on an axis, because a reader wants the number for // the row they are looking at, not a scale to estimate it against. func HBarChart(rows []Row, colour, unit string) template.HTML { + return english.HBarChart(rows, colour, unit) +} + +// HBarChart is the ranked chart with its figures, and what it says when it +// has none, in this language. +func (l *Lang) HBarChart(rows []Row, colour, unit string) template.HTML { if colour == "" { colour = "--viz-1" } if len(rows) == 0 { - return template.HTML(`

Nothing recorded yet.

`) //nolint:gosec // constant + return `

` + escapeText(l.T("chart.nothing_yet")) + `

` } maxV := 0.0 @@ -56,7 +62,7 @@ func HBarChart(rows []Row, colour, unit string) template.HTML { // width reads as no data rather than as a small number. pct = 0.5 } - value := compact(r.Value) + unitSuffix(unit) + value := l.Compact(r.Value) + unitSuffix(unit) title := r.Label + ": " + value if r.Note != "" { value += " · " + r.Note @@ -76,6 +82,11 @@ func HBarChart(rows []Row, colour, unit string) template.HTML { // No axes, no labels, no gridlines. It answers "rising, falling or flat" and // nothing else; a reader who wants a number reads the number it sits under. func Sparkline(values []float64, colour string) template.HTML { + return english.Sparkline(values, colour) +} + +// Sparkline is the trend line, named for a screen reader in this language. +func (l *Lang) Sparkline(values []float64, colour string) template.HTML { if colour == "" { colour = "--viz-1" } @@ -98,7 +109,7 @@ func Sparkline(values []float64, colour string) template.HTML { var b strings.Builder fmt.Fprintf(&b, ``, w, h, w, h) + `role="img" aria-label="%s">`, w, h, w, h, escapeText(l.T("chart.trend"))) b.WriteString(`` + esc(text) + ``) //nolint:gosec // escaped, and the scheme is allowlisted + } + return template.HTML(`` + esc(text) + ``) //nolint:gosec // escaped +} + // looksLikeEmail is deliberately crude: it decides whether to add a mailto:, // and being wrong means a link that does not open a mail client rather than // anything worse. diff --git a/internal/web/csp_test.go b/internal/web/csp_test.go index 978d201..5173460 100644 --- a/internal/web/csp_test.go +++ b/internal/web/csp_test.go @@ -28,7 +28,7 @@ func TestAPageNamesItsInlineScriptsAndNothingElse(t *testing.T) { func TestRenderedPagesCarryThePolicy(t *testing.T) { rec := httptest.NewRecorder() - renderStatus(rec, 401, "login", loginData{Error: "no"}) + renderStatus(rec, nil, 401, "login", loginData{Error: "no"}) if rec.Code != 401 { t.Errorf("status %d", rec.Code) } diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go index 14283a6..e2a02b3 100644 --- a/internal/web/dashboard.go +++ b/internal/web/dashboard.go @@ -5,6 +5,8 @@ import ( "html/template" "log/slog" "net/http" + "slices" + "strconv" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" @@ -94,8 +96,38 @@ type DashboardData struct { // way to tell that from a bug. Nav NavExtras RefreshSec int - Version string - Now string + // RefreshMeta is the reload for a browser without script. + RefreshMeta template.HTML + Version string + Now string +} + +// stateWords are every state a node, a replica, a shard or an engine's +// installation can be in. Each is a CSS class and a word; the class stays as +// it is and the word is looked up, as dash.state.. A state not listed +// here shows its key, which the catalogue test is there to stop. +var stateWords = []string{ + "ready", "cordoned", "draining", "unreachable", "lost", "unknown", + "placing", "pending", "starting", "loading", "stopped", "failed", + "installing", "unsupported", "skipped", +} + +func init() { + for _, s := range stateWords { + registerKeys("dash.state." + s) + } + // How bad a fabric's or an NVLink domain's problem is. + registerKeys("fabric.level.fail", "fabric.level.warn", "fabric.level.info") +} + +// refreshMeta is the meta element that reloads a page every sec seconds, +// for the