diff --git a/docs/install.md b/docs/install.md index 0e96048..68b5983 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1734,6 +1734,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 739eba5..82392e6 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -95,6 +95,7 @@ rather than repeated. | `latency_budget` | `time.Duration` | [JudgePolicy](#judgepolicy) | | `contact` | `string` | [Landing](#landing) | | `notice` | `string` | [Landing](#landing) | +| `language` | `string` | [Landing](#landing) | | `docs_sign_in` | `bool` | [Landing](#landing) | | `bin` | `string` | [LlamaCppConfig](#llamacppconfig) | | `parallel` | `int` | [LlamaCppConfig](#llamacppconfig) | @@ -908,6 +909,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. + ### `docs_sign_in` `bool` diff --git a/internal/cli/server.go b/internal/cli/server.go index e8e7289..17fa418 100644 --- a/internal/cli/server.go +++ b/internal/cli/server.go @@ -392,6 +392,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 b3bf488..de7015a 100644 --- a/internal/cli/serverinit.go +++ b/internal/cli/serverinit.go @@ -612,6 +612,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 9832d54..6f9694c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -253,6 +253,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"` // DocsSignIn serves the docs portal at /docs only to someone signed in. // Off, the portal is public like this page: its client guide is for the // person who has no account yet, and the operator documents describe diff --git a/internal/docgen/reference_gen.go b/internal/docgen/reference_gen.go index 3a8d3b9..1890260 100644 --- a/internal/docgen/reference_gen.go +++ b/internal/docgen/reference_gen.go @@ -113,6 +113,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: ""}, {Key: "docs_sign_in", Type: "bool", Doc: "DocsSignIn serves the docs portal at /docs only to someone signed in.\nOff, the portal is public like this page: its client guide is for the\nperson who has no account yet, and the operator documents describe\nllmgw in general — no address, model or key of this installation —\nwhich the project's repository publishes anyway. On, a visitor is sent\nto sign in, and without accounts the portal needs an API key.", Nested: ""}, }}, {Package: "engines", Name: "LlamaCppConfig", Title: "LlamaCppConfig", Doc: "", Fields: []Field{ diff --git a/internal/router/handlers.go b/internal/router/handlers.go index d9f7233..f1ed618 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 @@ -297,7 +300,7 @@ func (r *Router) Handler() http.Handler { if r.auth == nil { return mux } - exempt := []string{"/health", "/ready", "/leader", "/", "/about", "/_static/", "/claim/", + exempt := []string{"/health", "/ready", "/leader", "/", "/about", "/_static/", "/_lang/", "/claim/", "/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/", "/admin", "/admin/", "/chat", "/chat/", "/org", "/org/", "/setup/"} // The docs are public unless the site said otherwise, and then guarded by 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 d129cf6..1ffe7bf 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -247,6 +247,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 9476fec..dc21159 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