Merge C24 portal in English and Turkish
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
88d63fcb76
117 files changed
+8169
-2010
No files matched your search
@@ -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/<code>/` 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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(), `<html lang="tr">`) {
|
||||
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(), `<html lang="tr">`) {
|
||||
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(), `<html lang="en">`) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+80
-43
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+172
-68
@@ -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))
|
||||
}
|
||||
+57
-56
@@ -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, ", ")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+65
-50
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+159
-89
@@ -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():
|
||||
|
||||
+17
-26
@@ -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(`</svg>`)
|
||||
|
||||
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(`<div class="chart-wrap">` + b.String() + //nolint:gosec // built above
|
||||
`<span class="chart-peak">` + string(escapeText(peak)) + `</span></div>`)
|
||||
@@ -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)
|
||||
}
|
||||
+14
-3
@@ -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(`<p class="empty">Nothing recorded yet.</p>`) //nolint:gosec // constant
|
||||
return `<p class="empty">` + escapeText(l.T("chart.nothing_yet")) + `</p>`
|
||||
}
|
||||
|
||||
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, `<svg viewBox="0 0 %.0f %.0f" width="%.0f" height="%.0f" `+
|
||||
`role="img" aria-label="recent trend">`, w, h, w, h)
|
||||
`role="img" aria-label="%s">`, w, h, w, h, escapeText(l.T("chart.trend")))
|
||||
b.WriteString(`<polyline fill="none" stroke="var(` + cssVar(colour) + `)` +
|
||||
`" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" points="`)
|
||||
step := w / float64(len(values)-1)
|
||||
|
||||
@@ -69,7 +69,7 @@ type chatData struct {
|
||||
func Chat(user store.User, models []ChatModel, contexts []ChatContext, current, version string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
noStore(w)
|
||||
render(w, "chat", chatData{
|
||||
render(w, r, "chat", chatData{
|
||||
Nav: navFrom(r, "chat"),
|
||||
Contexts: contexts, Context: current,
|
||||
User: user, Models: models, CSRF: csrfFor(r),
|
||||
|
||||
+11
-9
@@ -10,6 +10,8 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func init() { registerErrorKey(store.ErrClaimed, "claim.err.claimed") }
|
||||
|
||||
// ClaimData is the one-time key delivery page.
|
||||
type ClaimData struct {
|
||||
Secret string
|
||||
@@ -55,7 +57,7 @@ func Claim(claims Claims, version string, log *slog.Logger) http.HandlerFunc {
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
|
||||
if token == "" {
|
||||
gone(w, &d)
|
||||
gone(w, r, &d)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,11 +67,11 @@ func Claim(claims Claims, version string, log *slog.Logger) http.HandlerFunc {
|
||||
if !errors.Is(err, store.ErrClaimed) {
|
||||
log.Warn("could not read a claim", "err", err)
|
||||
}
|
||||
gone(w, &d)
|
||||
gone(w, r, &d)
|
||||
return
|
||||
}
|
||||
d.ExpiresAt = c.ExpiresAt.Format(time.RFC1123)
|
||||
render(w, "claim", d)
|
||||
d.ExpiresAt = langOf(r).Format(c.ExpiresAt, time.RFC1123)
|
||||
render(w, r, "claim", d)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,20 +80,20 @@ func Claim(claims Claims, version string, log *slog.Logger) http.HandlerFunc {
|
||||
if !errors.Is(err, store.ErrClaimed) {
|
||||
log.Warn("could not consume a claim", "err", err)
|
||||
}
|
||||
gone(w, &d)
|
||||
gone(w, r, &d)
|
||||
return
|
||||
}
|
||||
log.Info("a key was collected")
|
||||
d.Secret = secret
|
||||
render(w, "claim", d)
|
||||
render(w, r, "claim", d)
|
||||
}
|
||||
}
|
||||
|
||||
// gone renders the one answer given for a used link, an expired one, and a
|
||||
// token that was never real. Distinguishing them would confirm that a token was
|
||||
// once valid.
|
||||
func gone(w http.ResponseWriter, d *ClaimData) {
|
||||
func gone(w http.ResponseWriter, r *http.Request, d *ClaimData) {
|
||||
d.Gone = true
|
||||
d.Message = store.ErrClaimed.Error()
|
||||
renderStatus(w, http.StatusGone, "claim", *d)
|
||||
d.Message = langOf(r).Err(store.ErrClaimed)
|
||||
renderStatus(w, r, http.StatusGone, "claim", *d)
|
||||
}
|
||||
+23
-17
@@ -86,8 +86,14 @@ model = "%[2]s"
|
||||
`, base, model)
|
||||
}
|
||||
|
||||
// connectFor builds what the page shows for a tool.
|
||||
// connectFor builds what the page shows for a tool, in English.
|
||||
func connectFor(tool, base, model, key string) connectView {
|
||||
return connectIn(english, tool, base, model, key)
|
||||
}
|
||||
|
||||
// connectIn builds what the page shows for a tool, its sentences in l. The
|
||||
// files, commands and paths are the same in every language.
|
||||
func connectIn(l *Lang, tool, base, model, key string) connectView {
|
||||
v := connectView{Tool: tool, Model: model, Key: key}
|
||||
for _, t := range editorTools {
|
||||
if t.Key == tool {
|
||||
@@ -100,23 +106,22 @@ func connectFor(tool, base, model, key string) connectView {
|
||||
v.Files = []connectFile{{Path: "~/.continue/assistants/llmgw.yaml", Name: "llmgw.yaml",
|
||||
Body: continueYAML(base, model, key)}}
|
||||
v.Command = fmt.Sprintf("curl -fsSL '%s' | LLMGW_KEY='%s' sh", script, key)
|
||||
v.Run = `In Continue, choose the "llmgw" assistant at the top of its panel.`
|
||||
v.Run = l.T("connect.run.continue")
|
||||
case "codex":
|
||||
v.Files = []connectFile{{Path: "~/.codex/config.toml", Name: "config.toml", Body: codexTOML(base, model)}}
|
||||
v.Command = fmt.Sprintf("curl -fsSL '%s' | sh", script)
|
||||
v.Run = fmt.Sprintf("LLMGW_API_KEY='%s' codex --profile llmgw", key)
|
||||
v.Notes = []string{
|
||||
"The command adds the two blocks above to ~/.codex/config.toml, replacing any it added before, and keeps a copy of the file as it was.",
|
||||
"Put export LLMGW_API_KEY=… in your shell's profile to leave it out of the command line.",
|
||||
}
|
||||
v.Notes = []string{l.T("connect.note.codex_blocks"), l.T("connect.note.codex_env")}
|
||||
case "cline":
|
||||
// The values are what Cline's own screen offers and takes, which is
|
||||
// English whatever language this page is in.
|
||||
v.Fields = []connectField{
|
||||
{"API Provider", "OpenAI Compatible"},
|
||||
{"Base URL", base + "/v1"},
|
||||
{"API Key", key},
|
||||
{"Model ID", model},
|
||||
{l.T("connect.field.provider"), "OpenAI Compatible"},
|
||||
{l.T("connect.field.base_url"), base + "/v1"},
|
||||
{l.T("connect.field.key"), key},
|
||||
{l.T("connect.field.model"), model},
|
||||
}
|
||||
v.Run = "Cline keeps these in VS Code's own secret storage, so they are set in its settings: open Cline, the gear, and fill them in."
|
||||
v.Run = l.T("connect.run.cline")
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -133,6 +138,7 @@ func (p *AccountPages) connect(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
l := langOf(r)
|
||||
tool := r.FormValue("tool")
|
||||
known := false
|
||||
for _, t := range editorTools {
|
||||
@@ -140,13 +146,13 @@ func (p *AccountPages) connect(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
model := strings.TrimSpace(r.FormValue("model"))
|
||||
if !known || !safeModel.MatchString(model) {
|
||||
p.renderProfile(w, r, sess, user, "", "choose a tool and a model")
|
||||
p.renderProfile(w, r, sess, user, "", l.T("connect.err.choose"))
|
||||
return
|
||||
}
|
||||
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 = ""
|
||||
@@ -154,20 +160,20 @@ func (p *AccountPages) connect(w http.ResponseWriter, r *http.Request) {
|
||||
name := tool + " " + time.Now().Format("2006-01-02")
|
||||
secret, k, err := store.NewKey(randomKeyID(), name, project)
|
||||
if err != nil {
|
||||
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 {
|
||||
msg := "something went wrong; try again"
|
||||
msg := l.T("profile.err.try_again")
|
||||
if err == store.ErrNotMember {
|
||||
msg = "you are not a member of that project"
|
||||
msg = l.T("profile.err.not_member")
|
||||
}
|
||||
p.renderProfile(w, r, sess, user, "", msg)
|
||||
return
|
||||
}
|
||||
p.log.Info("a user connected an editor", "user", user.ID, "tool", tool, "key", k.ID)
|
||||
c := connectFor(tool, baseURL(r), model, secret)
|
||||
c := connectIn(l, tool, baseURL(r), model, secret)
|
||||
p.render(w, r, sess, user, profileData{Connect: &c})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
@@ -35,6 +36,17 @@ func contactLink(contact string) (text, href string) {
|
||||
return c, ""
|
||||
}
|
||||
|
||||
// contactMarkup is the contact as a link, or as emphasised text when it is not
|
||||
// one. Both parts are escaped here, because the sentence it goes into is
|
||||
// markup; the href has already passed contactLink's scheme allowlist.
|
||||
func contactMarkup(text, href string) template.HTML {
|
||||
esc := template.HTMLEscapeString
|
||||
if href != "" {
|
||||
return template.HTML(`<a href="` + esc(href) + `">` + esc(text) + `</a>`) //nolint:gosec // escaped, and the scheme is allowlisted
|
||||
}
|
||||
return template.HTML(`<strong>` + esc(text) + `</strong>`) //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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+56
-17
@@ -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.<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 <noscript> in its head.
|
||||
//
|
||||
// Built here rather than written in the template because the inside of a
|
||||
// <noscript> is text to an HTML tokenizer, and the catalogue check would
|
||||
// read the element's attribute names there as English left on the page.
|
||||
func refreshMeta(sec int) template.HTML {
|
||||
return template.HTML(`<meta http-equiv="refresh" content="` + strconv.Itoa(sec) + `">`) //nolint:gosec // a number
|
||||
}
|
||||
|
||||
// Source is what the dashboard reads. An interface so the page can be rendered
|
||||
@@ -170,12 +202,14 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat
|
||||
refresh = 5 * time.Second
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
l := langOf(r)
|
||||
d := DashboardData{
|
||||
Menu: navFrom(r, "cluster"),
|
||||
OnAdminListener: onAdminListener,
|
||||
Nav: nav,
|
||||
Fleet: LoadFleetCharts(r.Context(), nav.History, nav.Window, log),
|
||||
Fleet: l.loadFleetCharts(r.Context(), nav.History, nav.Window, log),
|
||||
RefreshSec: int(refresh.Seconds()),
|
||||
RefreshMeta: refreshMeta(int(refresh.Seconds())),
|
||||
Version: version,
|
||||
Now: time.Now().UTC().Format(time.RFC3339),
|
||||
AuthEnabled: usage != nil,
|
||||
@@ -184,7 +218,12 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat
|
||||
d.Notice, d.Problem = r.URL.Query().Get("notice"), r.URL.Query().Get("problem")
|
||||
}
|
||||
if src != nil {
|
||||
d.Nodes = src.Nodes()
|
||||
// A copy, since the ages are rewritten in the reader's units
|
||||
// and the source's slice may be what it hands everybody.
|
||||
d.Nodes = slices.Clone(src.Nodes())
|
||||
for i := range d.Nodes {
|
||||
d.Nodes[i].LastSeenAgo = l.clockText(d.Nodes[i].LastSeenAgo)
|
||||
}
|
||||
d.CPU = cpuCapacityOf(d.Nodes)
|
||||
d.Models = src.Models()
|
||||
d.Replicas = src.Replicas("")
|
||||
@@ -206,8 +245,8 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat
|
||||
}
|
||||
d.NodePrefix = sharedPrefix(ids)
|
||||
if d.AnyGPUs {
|
||||
d.GPUMap = buildGPUMap(d.Nodes, d.Replicas, r.URL.Query().Get("gpumap"), d.NodePrefix)
|
||||
d.IdleGPUs = idleHoldings(d.Nodes, d.Replicas, d.NodePrefix)
|
||||
d.GPUMap = buildGPUMap(l, d.Nodes, d.Replicas, r.URL.Query().Get("gpumap"), d.NodePrefix)
|
||||
d.IdleGPUs = idleHoldings(l, d.Nodes, d.Replicas, d.NodePrefix)
|
||||
}
|
||||
for _, m := range d.Models {
|
||||
d.AnyFallbacks = d.AnyFallbacks || len(m.Fallbacks) > 0
|
||||
@@ -235,13 +274,13 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat
|
||||
log.Warn("could not read usage for the dashboard", "err", err)
|
||||
}
|
||||
d.Usage = rows
|
||||
d.addCharts(r.Context(), usage, log)
|
||||
d.addCharts(r.Context(), l, usage, log)
|
||||
d.addPeopleAndModels(r.Context(), usage, log)
|
||||
}
|
||||
// Only ever shown to an administrator, whatever wrapped it.
|
||||
d.Menu.Admin = true
|
||||
d.Menu.Mirror = d.Menu.Mirror || nav.Mirror
|
||||
render(w, "dashboard", d)
|
||||
render(w, r, "dashboard", d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +315,7 @@ func (d *DashboardData) addPeopleAndModels(ctx context.Context, usage UsageSourc
|
||||
//
|
||||
// Hourly, over 24 hours: fine enough to show a working day and coarse enough
|
||||
// that one slow minute does not look like an incident.
|
||||
func (d *DashboardData) addCharts(ctx context.Context, usage UsageSource, log *slog.Logger) {
|
||||
func (d *DashboardData) addCharts(ctx context.Context, l *Lang, usage UsageSource, log *slog.Logger) {
|
||||
buckets, err := usage.UsageBuckets(ctx, time.Now().Add(-24*time.Hour), time.Hour)
|
||||
if err != nil {
|
||||
log.Warn("could not read usage buckets for the dashboard", "err", err)
|
||||
@@ -290,19 +329,19 @@ func (d *DashboardData) addCharts(ctx context.Context, usage UsageSource, log *s
|
||||
requests.Colour, tokens.Colour, latency.Colour = "--accent", "--ok", "--warn"
|
||||
for _, b := range buckets {
|
||||
requests.Values = append(requests.Values, float64(b.Requests))
|
||||
requests.Labels = append(requests.Labels, hourLabel(b.Start, float64(b.Requests), "requests"))
|
||||
requests.Labels = append(requests.Labels, l.hourLabel(b.Start, float64(b.Requests), l.T("dash.unit.requests")))
|
||||
|
||||
tok := float64(b.Prompt + b.Completion)
|
||||
tokens.Values = append(tokens.Values, tok)
|
||||
tokens.Labels = append(tokens.Labels, hourLabel(b.Start, tok, "tokens"))
|
||||
tokens.Labels = append(tokens.Labels, l.hourLabel(b.Start, tok, l.T("dash.unit.tokens")))
|
||||
|
||||
latency.Values = append(latency.Values, float64(b.LatencyMs))
|
||||
latency.Labels = append(latency.Labels, hourLabel(b.Start, float64(b.LatencyMs), "ms mean"))
|
||||
latency.Labels = append(latency.Labels, l.hourLabel(b.Start, float64(b.LatencyMs), l.T("dash.unit.ms_mean")))
|
||||
}
|
||||
|
||||
d.RequestChart = BarChart(requests, "requests/h")
|
||||
d.TokenChart = BarChart(tokens, "tokens/h")
|
||||
d.LatencyChart = BarChart(latency, "ms")
|
||||
d.RequestChart = l.BarChart(requests, l.T("dash.unit.requests_h"))
|
||||
d.TokenChart = l.BarChart(tokens, l.T("dash.unit.tokens_h"))
|
||||
d.LatencyChart = l.BarChart(latency, "ms")
|
||||
d.HasCharts = true
|
||||
|
||||
// The headline figures. Numbers rather than charts, because a single
|
||||
@@ -315,8 +354,8 @@ func (d *DashboardData) addCharts(ctx context.Context, usage UsageSource, log *s
|
||||
d.Errors24h += b.Errors
|
||||
d.Spend24h += b.CostMicros
|
||||
}
|
||||
d.RequestSpark = Sparkline(requests.Values, "--viz-1")
|
||||
d.TokenSpark = Sparkline(tokens.Values, "--viz-3")
|
||||
d.RequestSpark = l.Sparkline(requests.Values, "--viz-1")
|
||||
d.TokenSpark = l.Sparkline(tokens.Values, "--viz-3")
|
||||
}
|
||||
|
||||
// unplaced collects the placement explanations for models short of replicas.
|
||||
|
||||
@@ -30,8 +30,12 @@ type FleetCharts struct {
|
||||
Window string
|
||||
// Err is set when the metrics store could not be read. Shown as a line of
|
||||
// text where the charts would be: an empty panel reads as a healthy cluster
|
||||
// with nothing happening, which is the opposite of not knowing.
|
||||
// with nothing happening, which is the opposite of not knowing. The
|
||||
// error is the metrics store's own, in English.
|
||||
Err string
|
||||
|
||||
// lang is the page's language, which the charts added later are drawn in.
|
||||
lang *Lang
|
||||
}
|
||||
|
||||
// fleetPanel is one chart and the query behind it.
|
||||
@@ -67,6 +71,13 @@ var fleetPanels = []struct {
|
||||
// there". Either the metrics store answered or it did not.
|
||||
func LoadFleetCharts(ctx context.Context, h History, window time.Duration,
|
||||
log *slog.Logger,
|
||||
) *FleetCharts {
|
||||
return english.loadFleetCharts(ctx, h, window, log)
|
||||
}
|
||||
|
||||
// loadFleetCharts is LoadFleetCharts drawn in this language.
|
||||
func (l *Lang) loadFleetCharts(ctx context.Context, h History, window time.Duration,
|
||||
log *slog.Logger,
|
||||
) *FleetCharts {
|
||||
if h == nil {
|
||||
return nil
|
||||
@@ -78,7 +89,7 @@ func LoadFleetCharts(ctx context.Context, h History, window time.Duration,
|
||||
// size and a bigger response to parse on every page load.
|
||||
step := window / 60
|
||||
|
||||
out := &FleetCharts{Window: windowWord(window)}
|
||||
out := &FleetCharts{Window: l.windowWord(window), lang: l}
|
||||
for _, p := range fleetPanels {
|
||||
series, err := h.RangeQuery(ctx, p.expr, p.nameBy, window, step)
|
||||
if err != nil {
|
||||
@@ -89,11 +100,26 @@ func LoadFleetCharts(ctx context.Context, h History, window time.Duration,
|
||||
out.Err = err.Error()
|
||||
return out
|
||||
}
|
||||
p.into(out, LineChart(series, p.unit, 4))
|
||||
p.into(out, l.LineChart(series, p.unit, 4))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// windowWord names a chart window the way a sentence in this language
|
||||
// would: "last hour", "son 6 saat". A window that is not whole minutes is
|
||||
// written as Go writes it, which is rare enough not to be worth words.
|
||||
func (l *Lang) windowWord(d time.Duration) string {
|
||||
switch {
|
||||
case d == time.Hour:
|
||||
return l.T("chart.window.hour")
|
||||
case d%time.Hour == 0:
|
||||
return l.N("chart.window.hours", int64(d.Hours()))
|
||||
case d%time.Minute == 0:
|
||||
return l.N("chart.window.minutes", int64(d.Minutes()))
|
||||
}
|
||||
return d.String()
|
||||
}
|
||||
|
||||
// LoadGPU adds the fleet's GPUs, by class: how busy on average, and how full
|
||||
// their memory. Asked only of a fleet with GPUs. A failure leaves these two
|
||||
// empty and the rest of the page as it was; they are not what the other
|
||||
@@ -106,12 +132,16 @@ func (f *FleetCharts) LoadGPU(ctx context.Context, h History, window time.Durati
|
||||
window = time.Hour
|
||||
}
|
||||
step := window / 60
|
||||
l := f.lang
|
||||
if l == nil {
|
||||
l = english
|
||||
}
|
||||
if s, err := h.RangeQuery(ctx, `avg by (class) (llmgw_gpu_utilization_percent) / 100`, "class", window, step); err == nil {
|
||||
f.GPUBusy = LineChart(s, "%", 6)
|
||||
f.GPUBusy = l.LineChart(s, "%", 6)
|
||||
}
|
||||
if s, err := h.RangeQuery(ctx,
|
||||
`sum by (class) (llmgw_gpu_memory_used_bytes) / sum by (class) (llmgw_gpu_memory_total_bytes)`,
|
||||
"class", window, step); err == nil {
|
||||
f.GPUMemory = LineChart(s, "%", 6)
|
||||
f.GPUMemory = l.LineChart(s, "%", 6)
|
||||
}
|
||||
}
|
||||
@@ -221,13 +221,13 @@ func Docs(version string, signIn bool) http.HandlerFunc {
|
||||
}
|
||||
key := strings.TrimSuffix(strings.ToLower(r.PathValue("doc")), ".md")
|
||||
if key == "" {
|
||||
render(w, "docs", d)
|
||||
render(w, r, "docs", d)
|
||||
return
|
||||
}
|
||||
for i := range docs {
|
||||
if docs[i].Key == key {
|
||||
d.Doc = &docs[i]
|
||||
render(w, "docs", d)
|
||||
render(w, r, "docs", d)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,15 @@ func CodeArchive(maxBody int64) func(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
|
||||
if err != nil || json.Unmarshal(body, &in) != nil {
|
||||
http.Error(w, "could not read the request", http.StatusBadRequest)
|
||||
http.Error(w, langOf(r).T("download.err.read"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
files := ExtractCode(in.Content)
|
||||
if len(files) == 0 {
|
||||
http.Error(w, "there is no code in that reply to download", http.StatusBadRequest)
|
||||
// Shown on the page's download button, so in the page's language:
|
||||
// the browser's request carries the same cookie and header.
|
||||
http.Error(w, langOf(r).T("download.err.no_code"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
@@ -24,11 +25,54 @@ type fabricPageData struct {
|
||||
// Data is the current fabric as JSON, for the drawing to start from
|
||||
// without a round trip. json.Marshal escapes <, > and &, so a switch
|
||||
// description cannot close the script it sits in.
|
||||
Data template.JS
|
||||
Data template.JS
|
||||
// Strings are what the drawing says, in the page's language, as JSON
|
||||
// beside the data: the script is a static file and has no catalogue.
|
||||
Strings template.JS
|
||||
Version string
|
||||
Now string
|
||||
}
|
||||
|
||||
// fabricJSStrings are the drawing's sentences and how many arguments each
|
||||
// takes. An argument is handed to the catalogue as "{0}", "{1}", which the
|
||||
// script fills in, so a translation moves them with %[2]s as anywhere else.
|
||||
var fabricJSStrings = []struct {
|
||||
key string
|
||||
args int
|
||||
}{
|
||||
{"map_failed", 1}, {"not_mapped", 0},
|
||||
{"tier.leaf", 0}, {"tier.spine", 0}, {"tier.core", 0}, {"tier.supercore", 0}, {"tier.n", 1},
|
||||
{"adapters_row", 0}, {"ports_used", 2}, {"hosts", 1}, {"cables_n", 1}, {"below_rate", 1},
|
||||
{"adapters_n", 1}, {"degraded_n", 1}, {"rail", 1}, {"not_ours", 0},
|
||||
{"dt.tier", 0}, {"dt.ports", 0}, {"dt.adapters", 0}, {"dt.adapter", 0}, {"dt.rail", 0},
|
||||
{"dt.leaf", 0}, {"dt.state", 0}, {"dt.rate", 0}, {"dt.sending", 0}, {"dt.receiving", 0},
|
||||
{"cabled", 2}, {"degraded_cables", 0}, {"all_good", 0}, {"port_to", 3}, {"close", 0},
|
||||
{"open_node", 1}, {"adapter", 0},
|
||||
{"stat.switches", 0}, {"stat.adapters", 0}, {"stat.cables", 0}, {"stat.degraded", 0},
|
||||
{"stat.rails", 0}, {"stat.aligned", 0}, {"stat.crossed", 0}, {"stat.sent", 0},
|
||||
}
|
||||
|
||||
func init() {
|
||||
for _, s := range fabricJSStrings {
|
||||
registerKeys("fabric.js." + s.key)
|
||||
}
|
||||
}
|
||||
|
||||
// fabricStrings is the drawing's sentences in l, with the decimal sign its
|
||||
// figures are written with.
|
||||
func fabricStrings(l *Lang) template.JS {
|
||||
m := map[string]string{"decimal": l.decimal}
|
||||
for _, s := range fabricJSStrings {
|
||||
args := make([]any, s.args)
|
||||
for i := range args {
|
||||
args[i] = "{" + strconv.Itoa(i) + "}"
|
||||
}
|
||||
m[s.key] = l.T("fabric.js."+s.key, args...)
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return template.JS(b) //nolint:gosec // json.Marshal escapes <, > and &
|
||||
}
|
||||
|
||||
// FabricsPage shows every physical fabric and, for one, its map.
|
||||
func FabricsPage(src Source, onAdminListener bool, version string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -38,7 +82,8 @@ func FabricsPage(src Source, onAdminListener bool, version string) http.HandlerF
|
||||
return
|
||||
}
|
||||
d := fabricPageData{Nav: navFrom(r, "cluster"), OnAdminListener: onAdminListener, Base: "/admin/fabrics",
|
||||
List: ts.Topologies(), Version: version, Now: time.Now().UTC().Format(time.RFC3339)}
|
||||
List: ts.Topologies(), Version: version, Now: time.Now().UTC().Format(time.RFC3339),
|
||||
Strings: fabricStrings(langOf(r))}
|
||||
if onAdminListener {
|
||||
d.Base = "/fabrics"
|
||||
}
|
||||
@@ -57,7 +102,7 @@ func FabricsPage(src Source, onAdminListener bool, version string) http.HandlerF
|
||||
d.Data = template.JS(b) //nolint:gosec // json.Marshal escapes <, > and &
|
||||
}
|
||||
noStore(w)
|
||||
render(w, "fabrics", d)
|
||||
render(w, r, "fabrics", d)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,8 @@ func rationed(q *ration, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !q.allow(clientAddr(r), time.Now()) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, "too many attempts from this address; wait a minute", http.StatusTooManyRequests)
|
||||
// Plain text, but read by the person whose form was refused.
|
||||
http.Error(w, langOf(r).T("login.err.rationed"), http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
|
||||
+79
-16
@@ -15,9 +15,14 @@ import (
|
||||
// hundred cards in a table are a hundred rows to read; on the map the one
|
||||
// that is not like the others is seen before anything is read.
|
||||
|
||||
// gpuMapModes are what the map can be coloured by.
|
||||
var gpuMapModes = []struct{ Key, Label string }{
|
||||
{"util", "busy"}, {"memory", "memory"}, {"temp", "temperature"},
|
||||
// gpuMapModes are what the map can be coloured by; each is named by the key
|
||||
// dash.gpumap.mode.<mode>.
|
||||
var gpuMapModes = []string{"util", "memory", "temp"}
|
||||
|
||||
func init() {
|
||||
for _, m := range gpuMapModes {
|
||||
registerKeys("dash.gpumap.mode." + m)
|
||||
}
|
||||
}
|
||||
|
||||
type gpuMap struct {
|
||||
@@ -61,20 +66,23 @@ type idleHolding struct {
|
||||
|
||||
// buildGPUMap draws the map from what the nodes last reported and where
|
||||
// replicas are. Nil for a fleet without GPUs.
|
||||
func buildGPUMap(nodes []adminapi.NodeView, reps []adminapi.ReplicaView, by, prefix string) *gpuMap {
|
||||
func buildGPUMap(l *Lang, nodes []adminapi.NodeView, reps []adminapi.ReplicaView, by, prefix string) *gpuMap {
|
||||
held := heldBy(reps)
|
||||
m := &gpuMap{By: by, Modes: gpuMapModes}
|
||||
m := &gpuMap{By: by}
|
||||
for _, k := range gpuMapModes {
|
||||
m.Modes = append(m.Modes, struct{ Key, Label string }{k, l.T("dash.gpumap.mode." + k)})
|
||||
}
|
||||
if m.By == "" {
|
||||
m.By = "util"
|
||||
}
|
||||
switch m.By {
|
||||
case "temp":
|
||||
m.Legend = "Blue is cool, red is 90 °C and over."
|
||||
m.Legend = l.T("dash.gpumap.legend.temp")
|
||||
case "memory":
|
||||
m.Legend = "Pale is empty, dark is full. Engines reserve most of a card's memory when they start."
|
||||
m.Legend = l.T("dash.gpumap.legend.memory")
|
||||
default:
|
||||
m.By = "util"
|
||||
m.Legend = "Pale is idle, dark green is fully busy. A dashed border is a card no replica holds."
|
||||
m.Legend = l.T("dash.gpumap.legend.util")
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if n.GPUTotal == 0 {
|
||||
@@ -91,25 +99,28 @@ func buildGPUMap(nodes []adminapi.NodeView, reps []adminapi.ReplicaView, by, pre
|
||||
if c.Model != "" {
|
||||
title = append(title, c.Model+" ("+c.Replica+")")
|
||||
} else {
|
||||
title = append(title, "free")
|
||||
title = append(title, l.T("dash.gpumap.free"))
|
||||
}
|
||||
s, sampled := sensorOf(n, i)
|
||||
if sampled {
|
||||
if s.MemoryTotalMB > 0 && s.UtilPercent >= 0 {
|
||||
title = append(title, fmt.Sprintf("%.0f%% busy", s.UtilPercent))
|
||||
title = append(title, l.T("dash.gpumap.busy", l.Num(s.UtilPercent, 0)))
|
||||
}
|
||||
if s.MemoryTotalMB > 0 {
|
||||
title = append(title, fmt.Sprintf("%.1f of %.0f GB", s.MemoryUsedMB/1024, s.MemoryTotalMB/1024))
|
||||
title = append(title, l.T("dash.gpumap.memory", l.Num(s.MemoryUsedMB/1024, 1), l.Num(s.MemoryTotalMB/1024, 0)))
|
||||
}
|
||||
if s.TempC > 0 {
|
||||
title = append(title, fmt.Sprintf("%.0f °C", s.TempC))
|
||||
title = append(title, l.Num(s.TempC, 0)+" °C")
|
||||
}
|
||||
if s.PowerW > 0 {
|
||||
title = append(title, fmt.Sprintf("%.0f W", s.PowerW))
|
||||
title = append(title, l.Num(s.PowerW, 0)+" W")
|
||||
}
|
||||
if len(s.Throttle) > 0 {
|
||||
title = append(title, "held back by "+strings.Join(s.Throttle, ", "))
|
||||
// The driver's own names for the reasons, which are
|
||||
// the same in every language.
|
||||
title = append(title, l.T("dash.gpumap.held_back", strings.Join(s.Throttle, ", ")))
|
||||
}
|
||||
// What wears a card out is said by the inventory, in English.
|
||||
title = append(title, s.Problems...)
|
||||
}
|
||||
c.Title = strings.Join(title, " · ")
|
||||
@@ -161,7 +172,7 @@ func cellColour(by string, s sensorView, sampled bool) (string, string) {
|
||||
|
||||
// idleHoldings are the replicas whose GPUs all read under 5% busy now:
|
||||
// capacity held and not asked for, the first place to find room.
|
||||
func idleHoldings(nodes []adminapi.NodeView, reps []adminapi.ReplicaView, prefix string) []idleHolding {
|
||||
func idleHoldings(l *Lang, nodes []adminapi.NodeView, reps []adminapi.ReplicaView, prefix string) []idleHolding {
|
||||
byID := map[string]adminapi.NodeView{}
|
||||
for _, n := range nodes {
|
||||
byID[n.ID] = n
|
||||
@@ -192,7 +203,7 @@ func idleHoldings(nodes []adminapi.NodeView, reps []adminapi.ReplicaView, prefix
|
||||
continue
|
||||
}
|
||||
if r.IdleSec >= 0 {
|
||||
h.Idle = (time.Duration(r.IdleSec) * time.Second).String()
|
||||
h.Idle = l.clock(time.Duration(r.IdleSec) * time.Second)
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
@@ -201,6 +212,58 @@ func idleHoldings(nodes []adminapi.NodeView, reps []adminapi.ReplicaView, prefix
|
||||
return out
|
||||
}
|
||||
|
||||
// clock is a duration the way Go writes one, 30m0s, with the units in this
|
||||
// language's letters: 30dk0sn. Exact to the second, unlike Age, because
|
||||
// how long a replica has sat unused is read against the idle threshold.
|
||||
func (l *Lang) clock(d time.Duration) string {
|
||||
s := d.String()
|
||||
if l == english {
|
||||
return s
|
||||
}
|
||||
// Runs of digits are kept, with the decimal point this language
|
||||
// writes; each run of letters between them is a unit.
|
||||
var b strings.Builder
|
||||
unit := func(u string) {
|
||||
switch u {
|
||||
case "h", "m", "s", "ms":
|
||||
b.WriteString(l.T("dash.clock." + u))
|
||||
default:
|
||||
b.WriteString(u)
|
||||
}
|
||||
}
|
||||
start := 0
|
||||
for i, r := range s {
|
||||
if (r >= '0' && r <= '9') || r == '.' || r == '-' {
|
||||
if start < i {
|
||||
unit(s[start:i])
|
||||
}
|
||||
start = i + 1
|
||||
if r == '.' {
|
||||
b.WriteString(l.decimal)
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
unit(s[start:])
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerKeys("dash.clock.h", "dash.clock.m", "dash.clock.s", "dash.clock.ms")
|
||||
}
|
||||
|
||||
// clockText is clock for a duration the control plane has already written
|
||||
// out, such as a node's last-seen age. What is not a duration is left as it
|
||||
// is.
|
||||
func (l *Lang) clockText(s string) string {
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return l.clock(d)
|
||||
}
|
||||
|
||||
type holder struct{ model, replica string }
|
||||
|
||||
func heldBy(reps []adminapi.ReplicaView) map[string]holder {
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// The portal speaks more than one language. Every string a person reads is
|
||||
// looked up by a key in a catalogue, one directory per language under
|
||||
// locales/, so that adding a language is adding a directory: the code names
|
||||
// no language but English, which is what everything falls back to.
|
||||
//
|
||||
// A catalogue is flat JSON, key to text. The text is a fmt format when the
|
||||
// caller passes arguments ("%s was approved", "%[2]s içinde %[1]d"), so a
|
||||
// language whose word order differs moves the arguments rather than asking
|
||||
// the code to. Keys under "_meta." are not text but how the language writes
|
||||
// numbers and dates.
|
||||
//
|
||||
// Several files per language rather than one, so the catalogue reads in the
|
||||
// same parts as the pages; a key defined twice in one language is an error at
|
||||
// start, not a silent winner.
|
||||
|
||||
//go:embed locales
|
||||
var localeFiles embed.FS
|
||||
|
||||
// Lang is one language the portal speaks.
|
||||
type Lang struct {
|
||||
// Code is the language's BCP 47 base tag: "en", "tr".
|
||||
Code string
|
||||
// Name is what the language calls itself, which is what the switcher
|
||||
// offers: a person looking for Türkçe looks for that word, not "Turkish".
|
||||
Name string
|
||||
|
||||
msgs map[string]string
|
||||
// decimal and group are the separators numbers are written with. An empty
|
||||
// group writes no thousands separator, which is how English figures have
|
||||
// always appeared on these pages.
|
||||
decimal, group string
|
||||
// date and dateTime are Go layouts.
|
||||
date, dateTime string
|
||||
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
// langCookie holds the language a person chose with the switcher. It outranks
|
||||
// what the browser asks for, because the person said so on this site and the
|
||||
// browser said so for every site.
|
||||
const langCookie = "llmgw_lang"
|
||||
|
||||
var (
|
||||
// languages is every language with a catalogue, English first and then by
|
||||
// code, which is the order the switcher shows.
|
||||
languages []*Lang
|
||||
// english is the fallback for a key another language lacks.
|
||||
english *Lang
|
||||
// defaultLang is the server's choice for a person who neither chose nor
|
||||
// sent a language this server speaks: landing.language.
|
||||
defaultLang atomic.Pointer[Lang]
|
||||
)
|
||||
|
||||
func init() {
|
||||
langs, err := loadLanguages(localeFiles)
|
||||
if err != nil {
|
||||
panic("web: " + err.Error())
|
||||
}
|
||||
languages = langs
|
||||
english = languages[0]
|
||||
defaultLang.Store(english)
|
||||
}
|
||||
|
||||
// loadLanguages reads every catalogue under locales/.
|
||||
func loadLanguages(fsys fs.FS) ([]*Lang, error) {
|
||||
dirs, err := fs.ReadDir(fsys, "locales")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []*Lang
|
||||
for _, d := range dirs {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
l, err := loadLang(fsys, d.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b *Lang) int {
|
||||
switch {
|
||||
case a.Code == b.Code:
|
||||
return 0
|
||||
case a.Code == "en":
|
||||
return -1
|
||||
case b.Code == "en":
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(a.Code, b.Code)
|
||||
})
|
||||
if len(out) == 0 || out[0].Code != "en" {
|
||||
return nil, errors.New("the English catalogue is missing from the binary")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadLang(fsys fs.FS, code string) (*Lang, error) {
|
||||
l := &Lang{Code: code, msgs: map[string]string{}}
|
||||
names, err := fs.Glob(fsys, path.Join("locales", code, "*.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, name := range names {
|
||||
b, err := fs.ReadFile(fsys, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := decodeCatalogue(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
for k, v := range m {
|
||||
if _, dup := l.msgs[k]; dup {
|
||||
return nil, fmt.Errorf("%s: %q is defined twice in %s", name, k, code)
|
||||
}
|
||||
l.msgs[k] = v
|
||||
}
|
||||
}
|
||||
l.Name = l.msgs["_meta.name"]
|
||||
if l.Name == "" {
|
||||
return nil, fmt.Errorf("the %s catalogue does not say its own name (_meta.name)", code)
|
||||
}
|
||||
l.decimal = cmpOr(l.msgs["_meta.decimal"], ".")
|
||||
l.group = l.msgs["_meta.group"]
|
||||
l.date = cmpOr(l.msgs["_meta.date"], "2006-01-02")
|
||||
l.dateTime = cmpOr(l.msgs["_meta.datetime"], "2006-01-02 15:04")
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// decodeCatalogue reads one file of a catalogue, refusing a key that appears
|
||||
// twice in it: encoding/json would quietly keep the second.
|
||||
func decodeCatalogue(b []byte) (map[string]string, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
if tok, err := dec.Token(); err != nil || tok != json.Delim('{') {
|
||||
return nil, errors.New("a catalogue is one JSON object")
|
||||
}
|
||||
m := map[string]string{}
|
||||
for dec.More() {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, _ := tok.(string)
|
||||
var v string
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return nil, fmt.Errorf("%q: %w", k, err)
|
||||
}
|
||||
if _, dup := m[k]; dup {
|
||||
return nil, fmt.Errorf("%q is defined twice", k)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func cmpOr(v, def string) string {
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Languages are the codes the portal speaks, for a message naming them.
|
||||
func Languages() []string {
|
||||
codes := make([]string, len(languages))
|
||||
for i, l := range languages {
|
||||
codes[i] = l.Code
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// SetDefaultLanguage is landing.language: what a person sees who neither
|
||||
// chose a language nor sent one this server speaks. Empty is English.
|
||||
//
|
||||
// Package state rather than a field of the pages, because the dashboard on
|
||||
// the admin listener and the portal on the client port are separate handlers
|
||||
// built in separate places and must agree.
|
||||
func SetDefaultLanguage(code string) error {
|
||||
if code == "" {
|
||||
defaultLang.Store(english)
|
||||
return nil
|
||||
}
|
||||
l := langByCode(code)
|
||||
if l == nil {
|
||||
return fmt.Errorf("the portal does not speak %q; it speaks %s", code, strings.Join(Languages(), ", "))
|
||||
}
|
||||
defaultLang.Store(l)
|
||||
return nil
|
||||
}
|
||||
|
||||
func langByCode(code string) *Lang {
|
||||
code = strings.ToLower(strings.TrimSpace(code))
|
||||
if i := strings.IndexAny(code, "-_"); i >= 0 {
|
||||
code = code[:i]
|
||||
}
|
||||
for _, l := range languages {
|
||||
if l.Code == code {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// langOf is the language a request is answered in: the person's own choice,
|
||||
// else the first language their browser asks for that this server speaks,
|
||||
// else the server's default.
|
||||
func langOf(r *http.Request) *Lang {
|
||||
if r == nil {
|
||||
return defaultLang.Load()
|
||||
}
|
||||
if c, err := r.Cookie(langCookie); err == nil {
|
||||
if l := langByCode(c.Value); l != nil {
|
||||
return l
|
||||
}
|
||||
}
|
||||
if h := r.Header.Get("Accept-Language"); h != "" {
|
||||
// Sorted by the weights the browser gave; a malformed header is
|
||||
// worth what can be read of it.
|
||||
tags, _, _ := language.ParseAcceptLanguage(h)
|
||||
for _, t := range tags {
|
||||
base, _ := t.Base()
|
||||
if l := langByCode(base.String()); l != nil {
|
||||
return l
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultLang.Load()
|
||||
}
|
||||
|
||||
// T is the text for key, formatted with args when there are any. A key this
|
||||
// language lacks is the English text, and a key English lacks is the key
|
||||
// itself — visible, which is the point, and caught by the catalogue test
|
||||
// before it ships.
|
||||
func (l *Lang) T(key string, args ...any) string {
|
||||
s, ok := l.msgs[key]
|
||||
if !ok && l != english {
|
||||
s, ok = english.msgs[key]
|
||||
}
|
||||
if !ok {
|
||||
s = key
|
||||
}
|
||||
// A text that takes no arguments is not a format, even when it is
|
||||
// handed some: "1 hour ago" says its count in words.
|
||||
if len(args) == 0 || !strings.Contains(s, "%") {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf(s, args...)
|
||||
}
|
||||
|
||||
// N is T for a sentence about a count: key.one when n is one and the
|
||||
// language has a singular there, else key.other, else key. The count is the
|
||||
// first argument. Turkish says "3 dakika" as it says "1 dakika", and needs
|
||||
// only key.other; English needs both.
|
||||
func (l *Lang) N(key string, n int64, args ...any) string {
|
||||
args = append([]any{n}, args...)
|
||||
// The singular only from this language's own catalogue: English's
|
||||
// "1 hour ago" is no fallback for a language that says "1 saat önce"
|
||||
// with the same form as any other count.
|
||||
if _, ok := l.msgs[key+".one"]; ok && n == 1 {
|
||||
return l.T(key+".one", args...)
|
||||
}
|
||||
if l.has(key + ".other") {
|
||||
return l.T(key+".other", args...)
|
||||
}
|
||||
return l.T(key, args...)
|
||||
}
|
||||
|
||||
// has is whether the key has text in this language or, failing that, in
|
||||
// English.
|
||||
func (l *Lang) has(key string) bool {
|
||||
if _, ok := l.msgs[key]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := english.msgs[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// TH is T as markup, for the few sentences with a link or emphasis in the
|
||||
// middle, which a translation has to be able to move. The catalogue is
|
||||
// compiled into the binary and trusted; the arguments are not, and are
|
||||
// escaped unless they are markup already.
|
||||
func (l *Lang) TH(key string, args ...any) template.HTML {
|
||||
safe := make([]any, len(args))
|
||||
for i, a := range args {
|
||||
switch v := a.(type) {
|
||||
case template.HTML:
|
||||
safe[i] = string(v)
|
||||
case string:
|
||||
safe[i] = template.HTMLEscapeString(v)
|
||||
case fmt.Stringer:
|
||||
safe[i] = template.HTMLEscapeString(v.String())
|
||||
default:
|
||||
safe[i] = template.HTMLEscapeString(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
return template.HTML(l.T(key, safe...)) //nolint:gosec // the catalogue is ours, the arguments are escaped above
|
||||
}
|
||||
|
||||
// errorKeys name the errors from outside this package that a page shows,
|
||||
// so a person reads them in their language. An error not listed is shown as
|
||||
// it is, in English, which is better than not at all.
|
||||
var (
|
||||
errorKeysMu sync.Mutex
|
||||
errorKeys []errorKey
|
||||
)
|
||||
|
||||
type errorKey struct {
|
||||
err error
|
||||
key string
|
||||
}
|
||||
|
||||
// registerErrorKey says which catalogue key an error is shown as. Called
|
||||
// from the init of the file whose page shows it.
|
||||
func registerErrorKey(err error, key string) {
|
||||
errorKeysMu.Lock()
|
||||
defer errorKeysMu.Unlock()
|
||||
errorKeys = append(errorKeys, errorKey{err, key})
|
||||
}
|
||||
|
||||
// Err is an error as a person reads it in this language.
|
||||
func (l *Lang) Err(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
errorKeysMu.Lock()
|
||||
keys := errorKeys
|
||||
errorKeysMu.Unlock()
|
||||
for _, ek := range keys {
|
||||
if errors.Is(err, ek.err) {
|
||||
return l.T(ek.key)
|
||||
}
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// dynamicKeys are keys a template or handler builds rather than writes out
|
||||
// — "key.state." and a state — which the catalogue test cannot find by
|
||||
// reading the source. Whoever builds one lists the whole set here.
|
||||
var (
|
||||
dynamicKeysMu sync.Mutex
|
||||
dynamicKeys []string
|
||||
)
|
||||
|
||||
// registerKeys lists keys that are looked up by a computed name.
|
||||
func registerKeys(keys ...string) {
|
||||
dynamicKeysMu.Lock()
|
||||
defer dynamicKeysMu.Unlock()
|
||||
dynamicKeys = append(dynamicKeys, keys...)
|
||||
}
|
||||
|
||||
// Numbers.
|
||||
|
||||
// Num writes v with places decimals, in this language's separators.
|
||||
func (l *Lang) Num(v float64, places int) string {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return "—"
|
||||
}
|
||||
return l.localNumber(strconv.FormatFloat(v, 'f', places, 64))
|
||||
}
|
||||
|
||||
// Int writes a whole number in this language's separators.
|
||||
func (l *Lang) Int(v int64) string {
|
||||
return l.localNumber(strconv.FormatInt(v, 10))
|
||||
}
|
||||
|
||||
// localNumber takes a number as Go writes it — an optional minus, digits,
|
||||
// optionally a point and more digits — and writes it this language's way:
|
||||
// 1234.5 is 1.234,5 in Turkish.
|
||||
func (l *Lang) localNumber(s string) string {
|
||||
if l.decimal == "." && l.group == "" {
|
||||
return s
|
||||
}
|
||||
sign := ""
|
||||
if strings.HasPrefix(s, "-") {
|
||||
sign, s = "-", s[1:]
|
||||
}
|
||||
whole, frac, hasFrac := strings.Cut(s, ".")
|
||||
if l.group != "" && len(whole) > 3 {
|
||||
var b strings.Builder
|
||||
for i, c := range whole {
|
||||
if i > 0 && (len(whole)-i)%3 == 0 {
|
||||
b.WriteString(l.group)
|
||||
}
|
||||
b.WriteRune(c)
|
||||
}
|
||||
whole = b.String()
|
||||
}
|
||||
if hasFrac {
|
||||
return sign + whole + l.decimal + frac
|
||||
}
|
||||
return sign + whole
|
||||
}
|
||||
|
||||
// Compact writes a large count the way someone says it aloud: 1.2k, 1,2 bin.
|
||||
func (l *Lang) Compact(v float64) string {
|
||||
switch {
|
||||
case v >= 1e9:
|
||||
return l.T("num.billions", l.Num(v/1e9, 1))
|
||||
case v >= 1e6:
|
||||
return l.T("num.millions", l.Num(v/1e6, 1))
|
||||
case v >= 1e3:
|
||||
return l.T("num.thousands", l.Num(v/1e3, 1))
|
||||
case v == float64(int64(v)):
|
||||
return l.Int(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.
|
||||
return l.localNumber(strconv.FormatFloat(v, 'g', 2, 64))
|
||||
default:
|
||||
return l.Num(v, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Units writes micro-units as the whole units a budget is written in.
|
||||
func (l *Lang) Units(micros int64) string {
|
||||
return l.Num(float64(micros)/1e6, 2)
|
||||
}
|
||||
|
||||
// Bytes writes a size in binary units, the way the shell does.
|
||||
func (l *Lang) Bytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return l.Int(n) + " B"
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for v := n / unit; v >= unit; v /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return l.Num(float64(n)/float64(div), 1) + " " + string("KMGTPE"[exp]) + "iB"
|
||||
}
|
||||
|
||||
// Latency writes a mean latency the way a person reads one: 5 ms, 8.8 s,
|
||||
// 1m 51s — not 110695 ms.
|
||||
func (l *Lang) Latency(ms float64) string {
|
||||
switch {
|
||||
case ms < 1000:
|
||||
return l.T("unit.ms", l.Num(ms, 0))
|
||||
case ms < 60_000:
|
||||
return l.T("unit.s", l.Num(ms/1000, 1))
|
||||
default:
|
||||
d := time.Duration(ms) * time.Millisecond
|
||||
return l.T("unit.min_s", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
}
|
||||
|
||||
// Ago says how long ago something was, to the unit that matters and no finer.
|
||||
func (l *Lang) Ago(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return l.T("time.never")
|
||||
}
|
||||
d := time.Since(t)
|
||||
n := func(v float64) int64 { return int64(v + 0.5) }
|
||||
switch {
|
||||
case d < 45*time.Second:
|
||||
return l.T("time.just_now")
|
||||
case d < time.Hour:
|
||||
return l.N("time.minutes_ago", n(d.Minutes()))
|
||||
case d < 36*time.Hour:
|
||||
return l.N("time.hours_ago", n(d.Hours()))
|
||||
case d < 14*24*time.Hour:
|
||||
return l.N("time.days_ago", n(d.Hours()/24))
|
||||
case d < 60*24*time.Hour:
|
||||
return l.N("time.weeks_ago", n(d.Hours()/24/7))
|
||||
default:
|
||||
return l.N("time.months_ago", n(d.Hours()/24/30))
|
||||
}
|
||||
}
|
||||
|
||||
// Age is a short duration in seconds as a column shows it: 45s, 12m, 3h05m,
|
||||
// 9d.
|
||||
func (l *Lang) Age(sec int64) string {
|
||||
d := time.Duration(sec) * time.Second
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return l.T("unit.age_s", sec)
|
||||
case d < time.Hour:
|
||||
return l.T("unit.age_m", int(d.Minutes()))
|
||||
case d < 48*time.Hour:
|
||||
return l.T("unit.age_hm", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
return l.T("unit.age_d", int(d.Hours()/24))
|
||||
}
|
||||
|
||||
// Date is a day, the way this language writes one: 2026-10-04, 04.10.2026.
|
||||
func (l *Lang) Date(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.Format(l.date)
|
||||
}
|
||||
|
||||
// DateTime is a day and a time of day.
|
||||
func (l *Lang) DateTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.Format(l.dateTime)
|
||||
}
|
||||
|
||||
var (
|
||||
weekdayNames = []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
||||
monthNames = []string{"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"}
|
||||
)
|
||||
|
||||
// Format is time.Format with the names of days and months in this
|
||||
// language, for layouts that use them ("Mon 15:04", "2 Jan").
|
||||
func (l *Lang) Format(t time.Time, layout string) string {
|
||||
out := t.Format(layout)
|
||||
if l == english {
|
||||
return out
|
||||
}
|
||||
// Go writes the English name only where the layout asked for one, and
|
||||
// the rest of a layout is digits and punctuation, so swapping the names
|
||||
// in the result is exact. Full names first: "Monday" contains "Mon".
|
||||
wd, mo := weekdayNames[t.Weekday()], monthNames[t.Month()-1]
|
||||
key := strings.ToLower(wd[:3])
|
||||
mkey := strings.ToLower(mo[:3])
|
||||
out = strings.ReplaceAll(out, wd, l.T("time.weekday."+key))
|
||||
out = strings.ReplaceAll(out, wd[:3], l.T("time.wd."+key))
|
||||
out = strings.ReplaceAll(out, mo, l.T("time.month."+mkey))
|
||||
out = strings.ReplaceAll(out, mo[:3], l.T("time.mon."+mkey))
|
||||
return out
|
||||
}
|
||||
|
||||
func init() {
|
||||
for _, d := range weekdayNames {
|
||||
k := strings.ToLower(d[:3])
|
||||
registerKeys("time.weekday."+k, "time.wd."+k)
|
||||
}
|
||||
for _, m := range monthNames {
|
||||
k := strings.ToLower(m[:3])
|
||||
registerKeys("time.month."+k, "time.mon."+k)
|
||||
}
|
||||
}
|
||||
|
||||
// toFloat accepts any number a template has in hand.
|
||||
func toFloat(v any) float64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case float32:
|
||||
return float64(n)
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case int32:
|
||||
return float64(n)
|
||||
case uint64:
|
||||
return float64(n)
|
||||
case uint32:
|
||||
return float64(n)
|
||||
case uint:
|
||||
return float64(n)
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
// toInt is toFloat for whole numbers, exact for the ones that are.
|
||||
func toInt(v any) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return int64(n), true
|
||||
case int64:
|
||||
return n, true
|
||||
case int32:
|
||||
return int64(n), true
|
||||
case uint64:
|
||||
return int64(min(n, math.MaxInt64)), true
|
||||
case uint32:
|
||||
return int64(n), true
|
||||
case uint:
|
||||
return int64(min(uint64(n), math.MaxInt64)), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// funcs are the template functions that speak this language. They replace
|
||||
// the English ones of the same name in the language's own set of templates.
|
||||
func (l *Lang) funcs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"t": l.T,
|
||||
"th": l.TH,
|
||||
"tn": func(key string, n any, args ...any) string {
|
||||
c, ok := toInt(n)
|
||||
if !ok {
|
||||
c = int64(toFloat(n))
|
||||
}
|
||||
return l.N(key, c, args...)
|
||||
},
|
||||
// lang is the code for <html lang>.
|
||||
"lang": func() string { return l.Code },
|
||||
// languages is what the switcher offers.
|
||||
"languages": func() []*Lang { return languages },
|
||||
// num is a figure with a fixed number of decimals; count a whole
|
||||
// one. Both write the language's separators, and neither belongs in
|
||||
// a style attribute or an SVG coordinate, which are always written
|
||||
// the C way.
|
||||
"num": func(v any, places int) string { return l.Num(toFloat(v), places) },
|
||||
"count": func(v any) string {
|
||||
if n, ok := toInt(v); ok {
|
||||
return l.Int(n)
|
||||
}
|
||||
return l.Num(toFloat(v), 0)
|
||||
},
|
||||
"pct1": func(v float64) string { return l.Num(v, 1) },
|
||||
"units": l.Units,
|
||||
"compact": func(v int64) string { return l.Compact(float64(v)) },
|
||||
"bytes": l.Bytes,
|
||||
"latency": l.Latency,
|
||||
"ago": l.Ago,
|
||||
"age": l.Age,
|
||||
"date": l.Date,
|
||||
"datetime": l.DateTime,
|
||||
"pctile": l.pctile,
|
||||
"buildLabel": func(b string) string {
|
||||
switch b {
|
||||
case "auto", "none":
|
||||
return l.T("engine.build." + b)
|
||||
case "cu13":
|
||||
return "CUDA 13"
|
||||
case "cu129":
|
||||
return "CUDA 12.9"
|
||||
case "cu12":
|
||||
return "CUDA 12"
|
||||
}
|
||||
return b
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerKeys("engine.build.auto", "engine.build.none")
|
||||
}
|
||||
|
||||
// templates parses the pages once per language, each set with that
|
||||
// language's functions, so a template says {{t "key"}} and gets the
|
||||
// language of the request it is answering without being handed it.
|
||||
func parseTemplates() {
|
||||
for _, l := range languages {
|
||||
l.tmpl = template.Must(template.New("").Funcs(funcs).Funcs(l.funcs()).ParseFS(files, "templates/*.html"))
|
||||
}
|
||||
}
|
||||
|
||||
// Language switching.
|
||||
|
||||
// SwitchLanguage is GET /_lang/{code}: it remembers the choice in a cookie
|
||||
// and goes back to the page the link was on.
|
||||
//
|
||||
// A link rather than a form, so it works on every page without a CSRF token
|
||||
// or a script. Changing someone's language is the whole of what a forged
|
||||
// request could do with it.
|
||||
func SwitchLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
l := langByCode(r.PathValue("code"))
|
||||
if l == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: langCookie, Value: l.Code, Path: "/",
|
||||
Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 365 * 24 * 3600,
|
||||
})
|
||||
http.Redirect(w, r, backTo(r), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// backTo is the page a switch came from — only a path on this same site, so
|
||||
// the link cannot be used to send someone elsewhere.
|
||||
func backTo(r *http.Request) string {
|
||||
ref, err := url.Parse(r.Referer())
|
||||
if err != nil || ref.Host == "" || ref.Host != r.Host || !strings.HasPrefix(ref.Path, "/") ||
|
||||
strings.HasPrefix(ref.Path, "//") || strings.HasPrefix(ref.Path, "/_lang/") {
|
||||
return "/"
|
||||
}
|
||||
if ref.RawQuery != "" {
|
||||
return ref.Path + "?" + ref.RawQuery
|
||||
}
|
||||
return ref.Path
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func TestTheChatPageSpeaksTurkishWhenAsked(t *testing.T) {
|
||||
h := Chat(store.User{Email: "a@example.com"},
|
||||
[]ChatModel{{Name: "qwen"}, {Name: "big-model", Simulated: true}}, nil, "", "v1")
|
||||
r := httptest.NewRequest(http.MethodGet, "http://x/chat", nil)
|
||||
r.Header.Set("Accept-Language", "tr-TR,tr;q=0.9")
|
||||
w := httptest.NewRecorder()
|
||||
h(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
page := w.Body.String()
|
||||
for _, want := range []string{
|
||||
`<html lang="tr">`,
|
||||
"<h1>Sohbet</h1>",
|
||||
">big-model (benzetim)<",
|
||||
"Yeni sohbet",
|
||||
`placeholder="Bir şey sorun…"`,
|
||||
`<output id="temp-out">0,7</output>`,
|
||||
// The script's own words come from the catalogue too.
|
||||
`"Uzunluk sınırında kesildi — devam etmesini isteyin."`,
|
||||
`decimal: ","`,
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("the Turkish chat page does not have %q", want)
|
||||
}
|
||||
}
|
||||
for _, not := range []string{"New conversation", "Cut off at the length limit", "Ask something"} {
|
||||
if strings.Contains(page, not) {
|
||||
t.Errorf("the Turkish chat page still says %q", not)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheChatDownloadRefusesInTheReadersLanguage(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/chat/download", strings.NewReader(`{"content":"no code here"}`))
|
||||
r.AddCookie(&http.Cookie{Name: langCookie, Value: "tr"})
|
||||
w := httptest.NewRecorder()
|
||||
CodeArchive(1<<20)(w, r)
|
||||
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "indirilecek kod yok") {
|
||||
t.Errorf("%d %q", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAboutPageSpeaksTurkishWhenAsked(t *testing.T) {
|
||||
code, body := get(t, About("v1.2.3"), "http://llmgw.example/about",
|
||||
map[string]string{"Accept-Language": "tr"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status %d", code)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`<html lang="tr">`,
|
||||
`<a href="/">Bu ağ geçidi</a>`,
|
||||
"<em>Kendi GPU'larınızın</em> önünde tek bir uç nokta.",
|
||||
"Sıfırdan ilk isteğe",
|
||||
"Sağlama toplamları",
|
||||
// The configurations are shown as they are.
|
||||
"reverse_proxy",
|
||||
"proxy_read_timeout",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the Turkish about page does not have %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "What it does") {
|
||||
t.Error("the Turkish about page still has English headings")
|
||||
}
|
||||
|
||||
// The published website is English whatever anyone's browser says.
|
||||
d := NewAboutData("v1.2.3")
|
||||
d.Site = true
|
||||
var b bytes.Buffer
|
||||
if err := RenderAbout(&b, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(b.String(), "What it does") || strings.Contains(b.String(), "Ne yapar") {
|
||||
t.Error("the website is not in English")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/promq"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
var clusterTR = map[string]string{"Accept-Language": "tr-TR,tr;q=0.9"}
|
||||
|
||||
func clusterTurkish(t *testing.T) *Lang {
|
||||
t.Helper()
|
||||
l := langByCode("tr")
|
||||
if l == nil {
|
||||
t.Fatal("the portal does not speak Turkish")
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func TestTheDashboardSpeaksTurkishWhenAsked(t *testing.T) {
|
||||
now := time.Now().Truncate(time.Hour)
|
||||
usage := fakeUsage{buckets: []store.Bucket{
|
||||
{Start: now.Add(-time.Hour), Requests: 1500, Prompt: 4000, Completion: 900, LatencyMs: 120},
|
||||
{Start: now, Requests: 95, Prompt: 9000, Completion: 2100, LatencyMs: 310},
|
||||
}}
|
||||
_, body := get(t, Dashboard(mapFleet{}, usage, "v1", 5*time.Second, true, NavExtras{}, nil),
|
||||
"http://127.0.0.1:9090/", clusterTR)
|
||||
for _, want := range []string{
|
||||
`<html lang="tr">`, "<h1>Küme</h1>", "Kapasite", "<h2>Düğümler</h2>",
|
||||
// A state is still its class, and a Turkish word.
|
||||
`<td class="state state-ready">hazır</td>`,
|
||||
// The GPU map: its modes, and what a card says under the pointer.
|
||||
"<strong>meşguliyet</strong>", "gpu07 GPU 2 · code (code-1) · 93% meşgul · 39,1 / 80 GB",
|
||||
// How long a replica has sat idle, in Turkish units.
|
||||
"30dk0sn önce",
|
||||
// The traffic chart's peak, with Turkish thousands.
|
||||
"en yüksek 1,5 bin istek/sa",
|
||||
"5 saniyede bir yenilenir",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the Turkish dashboard lacks %q", want)
|
||||
}
|
||||
}
|
||||
for _, english := range []string{"Capacity", "Holding hardware", "peak ", "requests/h", "No requests recorded"} {
|
||||
if strings.Contains(body, english) {
|
||||
t.Errorf("the Turkish dashboard still says %q", english)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNodePageWritesTurkishNumbers(t *testing.T) {
|
||||
q := func(expr string) string { return strings.Replace(expr, "%s", `"llmgw-node01"`, 1) }
|
||||
h := &fakeHistory{byExpr: map[string][]promq.Series{
|
||||
q(`llmgw_gpu_utilization_percent{node=%s} / 100`): {ts("0", 0.9, 0.97), ts("1", 0, 0)},
|
||||
q(`llmgw_gpu_temperature_celsius{node=%s}`): {ts("0", 65, 71)},
|
||||
}}
|
||||
r := httptest.NewRequest(http.MethodGet, "http://x/admin/nodes/llmgw-node01", nil)
|
||||
r.AddCookie(&http.Cookie{Name: langCookie, Value: "tr"})
|
||||
w := httptest.NewRecorder()
|
||||
nodeMux(gpuSource{}, h).ServeHTTP(w, r)
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{
|
||||
"64 çekirdek", "bellek 64%", "71,2 <span class=\"note\">/ 80 GB", "1.980 MHz",
|
||||
"3 düzeltilemeyen ECC hatası", "<h2>GPU'lar</h2>",
|
||||
"Meşgul GPU, karta göre", "GPU sıcaklığı, °C", "Son bir saat",
|
||||
// The chart's axis and peak in Turkish figures.
|
||||
"en yüksek 100%",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the Turkish node page lacks %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "uncorrected") || strings.Contains(body, "GPU busy, by card") {
|
||||
t.Error("the Turkish node page still speaks English")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartTooltipsNameTheDayInTurkish(t *testing.T) {
|
||||
tr := clusterTurkish(t)
|
||||
// 5 October 2026 is a Monday.
|
||||
at := time.Date(2026, 10, 5, 15, 4, 0, 0, time.Local)
|
||||
if got := tr.hourLabel(at, 1500, tr.T("dash.unit.requests")); got != "Pzt 15:04 — 1,5 bin istek" {
|
||||
t.Errorf("a Turkish tooltip reads %q", got)
|
||||
}
|
||||
if got := hourLabel(at, 1500, "requests"); got != "Mon 15:04 — 1.5k requests" {
|
||||
t.Errorf("the English tooltip changed: %q", got)
|
||||
}
|
||||
bars := string(tr.BarChart(Series{Values: []float64{1, 2500}, Labels: []string{tr.hourLabel(at, 1, "istek")}}, "istek/sa"))
|
||||
if !strings.Contains(bars, "<title>Pzt 15:04 — 1 istek</title>") || !strings.Contains(bars, "en yüksek 2,5 bin istek/sa") {
|
||||
t.Errorf("the Turkish bar chart:\n%s", bars)
|
||||
}
|
||||
if got := string(tr.BarChart(Series{}, "istek")); !strings.Contains(got, "kayıt yok") {
|
||||
t.Errorf("an empty Turkish chart: %s", got)
|
||||
}
|
||||
// The axis of a line chart, and its legend's fold.
|
||||
c := tr.LineChart([]promq.Series{ts("a", 0.001, 0.004), ts("b", 0, 0.001), ts("c", 0, 0.001),
|
||||
ts("d", 0, 0.001), ts("e", 0, 0.001)}, "%", 4)
|
||||
if !strings.Contains(string(c.SVG), ">0,5%<") || c.Peak != "0,5%" {
|
||||
t.Errorf("a Turkish percent axis: peak %q\n%s", c.Peak, c.SVG)
|
||||
}
|
||||
if last := c.Legend[len(c.Legend)-1]; last.Name != "diğer" {
|
||||
t.Errorf("the folded line is called %q", last.Name)
|
||||
}
|
||||
// The coordinates are written the C way whatever the language.
|
||||
if regexp.MustCompile(`points="[^"]*\d,\d+,`).MatchString(string(c.SVG)) {
|
||||
t.Error("a Turkish decimal comma reached an SVG coordinate")
|
||||
}
|
||||
if got := tr.windowWord(6 * time.Hour); got != "6 saat" {
|
||||
t.Errorf("six hours in Turkish: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFabricMapIsHandedItsWordsInThePagesLanguage(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "http://x/admin/fabrics", nil)
|
||||
r.Header.Set("Accept-Language", "tr")
|
||||
w := httptest.NewRecorder()
|
||||
FabricsPage(topoSource{}, false, "v1")(w, r)
|
||||
body := w.Body.String()
|
||||
m := regexp.MustCompile(`(?s)<script type="application/json" id="fabric-i18n">(.*?)</script>`).FindStringSubmatch(body)
|
||||
if m == nil {
|
||||
t.Fatalf("the page hands the map no words:\n%s", body)
|
||||
}
|
||||
var words map[string]string
|
||||
if err := json.Unmarshal([]byte(m[1]), &words); err != nil {
|
||||
t.Fatalf("the words are not JSON: %v\n%s", err, m[1])
|
||||
}
|
||||
for k, want := range map[string]string{
|
||||
"decimal": ",", "stat.switches": "anahtarlar", "ports_used": "{0}/{1} port",
|
||||
"map_failed": "Harita çıkarılamadı: {0}",
|
||||
} {
|
||||
if words[k] != want {
|
||||
t.Errorf("%s is %q, want %q", k, words[k], want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"<h1>Fabric'ler</h1>", "Görünümü sıfırla", `placeholder="Düğüm bul"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the Turkish fabrics page lacks %q", want)
|
||||
}
|
||||
}
|
||||
// Every sentence the script falls back to has a Turkish text.
|
||||
for _, s := range fabricJSStrings {
|
||||
if words[s.key] == "" || strings.Contains(words[s.key], "%!") {
|
||||
t.Errorf("the map's %q is %q", s.key, words[s.key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNVLinkAndLogPagesSpeakTurkish(t *testing.T) {
|
||||
_, body := get(t, NVLinkPage(nvlSource{}, false, "v1"), "http://x/admin/nvlink", clusterTR)
|
||||
for _, want := range []string{"<h1>NVLink alanları</h1>", "tepsi kullanılabilir", `class="tag bad">hata</span>`, "30 saniyede bir yenilenir"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the Turkish NVLink page lacks %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/admin/nodes/n6/logs?source=agent", nil)
|
||||
req.SetPathValue("id", "n6")
|
||||
req.Header.Set("Accept-Language", "tr")
|
||||
rec := httptest.NewRecorder()
|
||||
NodeLogsPage(logsFleet{}, false, "test")(rec, req)
|
||||
for _, want := range []string{"ajanın günlüğü", "son 300 satır", "izle"} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("the Turkish log page lacks %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAChosenBuildIsAnnouncedInTheChoosersLanguage(t *testing.T) {
|
||||
tr := clusterTurkish(t)
|
||||
if got := buildNotice(tr, "gpu07", "vllm", "cu129"); !strings.Contains(got, "CUDA 12.9 için derlenmiş") {
|
||||
t.Errorf("in Turkish: %q", got)
|
||||
}
|
||||
if got := buildNotice(english, "gpu07", "vllm", "none"); got != "gpu07 installs vllm no longer; the node is told now, and installs it in the background" {
|
||||
t.Errorf("in English: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
||||
)
|
||||
|
||||
// mirrorInTurkish asks the mirror page for path as somebody who chose
|
||||
// Turkish, with a form when there is one.
|
||||
func mirrorInTurkish(t *testing.T, mux *http.ServeMux, path string, form url.Values) string {
|
||||
t.Helper()
|
||||
method, body := http.MethodGet, ""
|
||||
if form != nil {
|
||||
method, body = http.MethodPost, form.Encode()
|
||||
}
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if form != nil {
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
r.AddCookie(&http.Cookie{Name: SessionCookie, Value: "good"})
|
||||
r.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
|
||||
r.AddCookie(&http.Cookie{Name: langCookie, Value: "tr"})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, r)
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
func wantAll(t *testing.T, what, body string, want ...string) {
|
||||
t.Helper()
|
||||
for _, s := range want {
|
||||
if !strings.Contains(body, s) {
|
||||
t.Errorf("%s lacks %q", what, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMirrorPageSpeaksTurkishWhenAsked(t *testing.T) {
|
||||
m := &fakeMirror{models: []mirror.Status{
|
||||
{Model: "qwen", Version: "2026-09", File: "model.gguf", State: mirror.Present,
|
||||
BytesDone: 491400032, BytesTotal: 491400032},
|
||||
{Model: "llama", Version: "v1", File: "l.gguf", State: mirror.Fetching,
|
||||
BytesDone: 1 << 20, BytesTotal: 4 << 20},
|
||||
}}
|
||||
reg := &fakeRegistry{results: []hf.Model{{ID: "Qwen/Qwen3-8B-GGUF", Downloads: 1247,
|
||||
CreatedAt: time.Now().Add(-3 * time.Hour)}}}
|
||||
mux := mirrorPageWith(t, m, reg, true)
|
||||
|
||||
held := mirrorInTurkish(t, mux, "/admin/mirror", nil)
|
||||
wantAll(t, "the held tab", held,
|
||||
`<html lang="tr">`, "<h1>Model aynası</h1>", ">Aynadakiler <span class=\"count\">1 · 1 iniyor</span>",
|
||||
"Hugging Face'i keşfet", "URL ile indir",
|
||||
"468,6 MiB", // the size, written the Turkish way
|
||||
"4,0 MiB içinden %25",
|
||||
"adresi kopyala", "sunulmuyor",
|
||||
`data-confirm="qwen/2026-09 ve içindeki tüm dosyalar kaldırılsın mı?"`,
|
||||
`"kopyalandı"`, // the copy button's script speaks it too
|
||||
)
|
||||
for _, english := range []string{"Model mirror", "copy address", "not served", "so far"} {
|
||||
if strings.Contains(held, english) {
|
||||
t.Errorf("the Turkish page still says %q", english)
|
||||
}
|
||||
}
|
||||
|
||||
explore := mirrorInTurkish(t, mux, "/admin/mirror?tab=explore", nil)
|
||||
wantAll(t, "the explorer", explore,
|
||||
"Gündemde", "En çok indirilen", "Her boyut", ">Ara</button>",
|
||||
"<th>Depo</th>", "1,2 bin", ">3 sa<", `"Yükleniyor…"`)
|
||||
|
||||
fetch := mirrorInTurkish(t, mux, "/admin/mirror?tab=fetch", nil)
|
||||
wantAll(t, "the fetch tab", fetch, "Kaynak URL", "İndirmeyi başlat", "Yine de indir")
|
||||
|
||||
// What a change answers is in the same language as the page it came from.
|
||||
done := mirrorInTurkish(t, mux, "/admin/mirror/fetch", url.Values{"csrf": {"tok"},
|
||||
"source": {"http://x/a.gguf", "http://x/b.gguf"}, "model": {"qwen"}, "version": {"2026-10"}})
|
||||
wantAll(t, "the fetch's answer", done, "qwen/2026-10 indirilmeye başlandı (2 dosya)")
|
||||
refused := mirrorInTurkish(t, mux, "/admin/mirror/fetch", url.Values{"csrf": {"tok"}, "tab": {"fetch"}})
|
||||
wantAll(t, "the refusal", refused, "bir kaynak URL gerekli")
|
||||
}
|
||||
|
||||
func TestAHeldModelsCardSpeaksTurkish(t *testing.T) {
|
||||
fleet := &roomyFleet{more: 2, blocked: []string{"n2: 2 GB free of 32, profile needs 6"}}
|
||||
cat := coderCatalog()
|
||||
mux := mirrorPageDeps(t, MirrorDeps{Mirror: &fakeMirror{models: []mirror.Status{
|
||||
{Model: "coder", Version: "v1", File: "c.gguf", State: mirror.Present, BytesDone: 5 << 30},
|
||||
}}, Catalog: cat, Fleet: fleet}, true)
|
||||
|
||||
card := mirrorInTurkish(t, mux, "/admin/mirror?tab=held&open=coder:v1", nil)
|
||||
wantAll(t, "the card", card,
|
||||
"Şu anda 2 tane daha için yer var: n1, n1.", ">hazır</span>", "1sa01dk", "3,5 çekirdek",
|
||||
"Sürekli çalışan", "varsayılan (standart)", "Kaydet", "LoRA adaptörleri")
|
||||
|
||||
msg := mirrorInTurkish(t, mux, "/admin/mirror/replicas", url.Values{"csrf": {"tok"},
|
||||
"model": {"coder"}, "min": {"2"}, "max": {"4"}, "open": {"coder:v1"}})
|
||||
wantAll(t, "the change's answer", msg,
|
||||
"coder 2 replikayla, yük altında en çok 4 replikayla çalışıyor; şu anda 3 tanesine yer var")
|
||||
}
|
||||
|
||||
func TestServingPlansAreDescribedInTurkish(t *testing.T) {
|
||||
tr := langByCode("tr")
|
||||
form := url.Values{
|
||||
"name": {"m"}, "source": {"http://x/m"}, "engine": {"llamacpp"}, "replicas": {"1"},
|
||||
"context_length": {"4096"}, "ctx_size": {"16384"}, "parallel": {"4"},
|
||||
"placement": {"gpu:t4"}, "gpus": {"4"}, "min_memory_gb": {"8"},
|
||||
}
|
||||
m, err := buildModel(tr, form.Get)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := describeServe(tr, m), "llama.cpp, tek düğümün 4 adet t4 GPU'su üzerinde, "+
|
||||
"8 GB ana makine belleği, 16.384 token bağlam havuzu, 4 paralel isteğe bölünmüş (her birine 4.096)"; got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
cpu := catalog.Model{Profiles: []catalog.Profile{{Engine: "llamacpp", CPUCores: 6}}}
|
||||
if got := describeServe(tr, cpu); got != "llama.cpp, CPU üzerinde, 6 çekirdek" {
|
||||
t.Errorf("on the CPU: %q", got)
|
||||
}
|
||||
|
||||
form.Set("context_length", "çok")
|
||||
if _, err := buildModel(tr, form.Get); err == nil || err.Error() != "bağlam uzunluğu tam sayı olmalı" {
|
||||
t.Errorf("a bad number is refused as %v", err)
|
||||
}
|
||||
form.Set("context_length", "4096")
|
||||
form.Set("engine", "vllm")
|
||||
form.Set("placement", "cpu")
|
||||
if _, err := buildModel(tr, form.Get); err == nil || !strings.Contains(err.Error(), "GPU sınıfı seçin") {
|
||||
t.Errorf("vLLM on the CPU is refused as %v", err)
|
||||
}
|
||||
|
||||
// The plan the form opens with, on a fleet with no GPU and no vLLM.
|
||||
page := mirrorInTurkish(t, servePage(t, []mirror.Status{held("coder", "v1", "m.gguf")}, &fakeCatalog{}, 0),
|
||||
"/admin/mirror?serve=coder:v1", nil)
|
||||
wantAll(t, "the serve form", page,
|
||||
"<code>http://mirror:8081/coder/v1/m.gguf</code>, <strong>llamacpp</strong> ile sunulacak.",
|
||||
`<option value="cpu" selected>CPU: bu kümede GPU yok</option>`,
|
||||
"Kataloğa ekle", "Gereken RAM (GB)", "Bellek: ağırlıkların bir buçuk katı artı iki.",
|
||||
`"Şöyle çalışacak: %s."`)
|
||||
|
||||
vllm := mirrorInTurkish(t, servePage(t, []mirror.Status{held("big", "v1", "model.safetensors")}, &fakeCatalog{}, 0, "llamacpp"),
|
||||
"/admin/mirror?serve=big:v1", nil)
|
||||
wantAll(t, "the vLLM warning", vllm,
|
||||
"hiçbir düğüm vllm kurulu olduğunu bildirmiyor", "Bu kümede GPU da yok")
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// peopleInTurkish sends a request to an admin page as a signed-in admin who chose
|
||||
// Turkish with the switcher, and returns the page with its white space
|
||||
// folded, so a sentence can be looked for across the template's lines.
|
||||
func peopleInTurkish(t *testing.T, mux *http.ServeMux, method, path string, form url.Values) string {
|
||||
t.Helper()
|
||||
var r *http.Request
|
||||
if form != nil {
|
||||
r = httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
} else {
|
||||
r = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
r.AddCookie(&http.Cookie{Name: SessionCookie, Value: "good"})
|
||||
r.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
|
||||
r.AddCookie(&http.Cookie{Name: langCookie, Value: "tr"})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s %s: status %d: %s", method, path, w.Code, w.Body.String())
|
||||
}
|
||||
return strings.Join(strings.Fields(w.Body.String()), " ")
|
||||
}
|
||||
|
||||
func TestThePeoplePageSpeaksTurkishWhenAsked(t *testing.T) {
|
||||
q := oneTeam()
|
||||
rpm := 60
|
||||
q.limits["org:org-1"] = store.Limits{Scope: store.ScopeOrg, ScopeID: "org-1", RPM: &rpm}
|
||||
u := &fakeScopeSeries{byGroup: map[store.Grouping][]store.UsageSeries{
|
||||
store.GroupByOrg: {{Grouping: store.GroupByOrg, ID: "org-1", Name: "Scania", Requests: 1234,
|
||||
Buckets: []store.Bucket{{Start: time.Now().Add(-time.Hour), Requests: 1234}}}},
|
||||
}}
|
||||
body := peopleInTurkish(t, peoplePage(t, q, u), http.MethodGet, "/admin/users?sel=org:org-1", nil)
|
||||
|
||||
for _, want := range []string{
|
||||
`<html lang="tr">`,
|
||||
"<title>Kişiler · llmgw</title>",
|
||||
`aria-current="page">Kuruluşlar ve kişiler`,
|
||||
"<h2>Kuruluşlar, ekipler ve projeler</h2>",
|
||||
`<span class="q-kind">kuruluş</span>`,
|
||||
"<dt>İstek/dk</dt><dd>60</dd>",
|
||||
"Kullanım, son 7 gün",
|
||||
"1,2 bin istek", // the count written the Turkish way
|
||||
"Kuruluş ekle",
|
||||
"Tüm hesaplar",
|
||||
"Scania için kota", // the title handed to the quota form
|
||||
"<code>llmgw users promote</code>",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the Turkish people page lacks %q", want)
|
||||
}
|
||||
}
|
||||
for _, english := range []string{"Organizations, teams and projects", "All accounts", "requests", "Quota for"} {
|
||||
if strings.Contains(body, english) {
|
||||
t.Errorf("the Turkish people page still says %q", english)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAChangeToTheTreeIsReportedInTheReadersLanguageAndAuditedInEnglish(t *testing.T) {
|
||||
s := &fakeAdmin{}
|
||||
am := &auditMem{}
|
||||
mux := tenancyPageWith(t, s, func(p *AccountPages) { p.SetAudit(am) })
|
||||
|
||||
body := peopleInTurkish(t, mux, http.MethodPost, "/admin/tenancy/org", url.Values{"csrf": {"tok"}, "name": {"Scania"}})
|
||||
if !strings.Contains(body, "Scania kuruluşu oluşturuldu") {
|
||||
t.Error("the page did not say in Turkish that the organization was created")
|
||||
}
|
||||
s.err = store.ErrDuplicateName
|
||||
body = peopleInTurkish(t, mux, http.MethodPost, "/admin/tenancy/org", url.Values{"csrf": {"tok"}, "name": {"Scania"}})
|
||||
if !strings.Contains(body, "burada bu adla zaten bir tane var") {
|
||||
t.Error("a refusal was not said in Turkish")
|
||||
}
|
||||
|
||||
// The audit log is a machine record, read and searched in one language.
|
||||
if len(am.got) != 2 {
|
||||
t.Fatalf("recorded %d", len(am.got))
|
||||
}
|
||||
if !strings.Contains(am.got[0].Detail, "organization Scania created") {
|
||||
t.Errorf("the audit record is not in English: %s", am.got[0].Detail)
|
||||
}
|
||||
if !strings.Contains(am.got[1].Detail, "there is already one of those here") {
|
||||
t.Errorf("the refusal's audit record is not in English: %s", am.got[1].Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAccountChangeNamesThePersonInTurkish(t *testing.T) {
|
||||
mux := tenancyPage(t, &fakeAdmin{}, true)
|
||||
body := peopleInTurkish(t, mux, http.MethodPost, "/admin/users/u2/leave",
|
||||
url.Values{"csrf": {"tok"}, "scope": {"project:proj-9"}})
|
||||
if !strings.Contains(body, "u2: proj-9 içinden çıkarıldı; orada oluşturduğu 1 anahtar iptal edildi") {
|
||||
t.Errorf("the change was not said in Turkish: %s", body)
|
||||
}
|
||||
body = peopleInTurkish(t, mux, http.MethodPost, "/admin/users/u1/suspend", url.Values{"csrf": {"tok"}})
|
||||
if !strings.Contains(body, "kendi hesabınızı askıya alamazsınız") {
|
||||
t.Error("refusing to suspend oneself was not said in Turkish")
|
||||
}
|
||||
}
|
||||
|
||||
// guardSource is the guardrails page's store, with one of everything.
|
||||
type guardSource struct{ at time.Time }
|
||||
|
||||
func (g guardSource) GuardEvents(context.Context, time.Time, int) ([]store.GuardEvent, error) {
|
||||
return []store.GuardEvent{{At: g.at, RequestID: "req_1", ProjectID: "proj-1", Model: "qwen",
|
||||
Status: 400, Action: "blocked", Policy: "base", Category: "iban", GuardMs: 1234}}, nil
|
||||
}
|
||||
|
||||
func (g guardSource) GuardCounts(context.Context, time.Time) ([]store.GuardCount, error) {
|
||||
return []store.GuardCount{{ProjectID: "", Action: "redacted", Policy: "base", Category: "email", Count: 1500}}, nil
|
||||
}
|
||||
|
||||
func (g guardSource) GuardAttachments(context.Context) ([]guard.Attachment, error) {
|
||||
return []guard.Attachment{{Scope: "project", ScopeID: "proj-1", Policy: "finance"}}, nil
|
||||
}
|
||||
|
||||
func (g guardSource) AllProjects(context.Context) ([]store.ProjectPath, error) {
|
||||
return []store.ProjectPath{{ID: "proj-1", Path: "Acme/fin/payroll"}}, nil
|
||||
}
|
||||
|
||||
func TestTheGuardrailsPageSpeaksTurkishWhenAsked(t *testing.T) {
|
||||
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
|
||||
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
mux := http.NewServeMux()
|
||||
at := time.Date(2026, 10, 4, 9, 5, 7, 0, time.UTC)
|
||||
p.RegisterAdminGuard(mux, guardSource{at: at}, guard.Config{
|
||||
Default: []string{"base"}, Policies: map[string]guard.Policy{"base": {}, "finance": {}}})
|
||||
|
||||
body := peopleInTurkish(t, mux, http.MethodGet, "/admin/guard", nil)
|
||||
for _, want := range []string{
|
||||
"<title>Koruma kuralları · llmgw</title>",
|
||||
"<h2>Politikalar</h2>",
|
||||
"<code>server.yaml</code> içinde tanımlı:",
|
||||
"<td>proje</td><td>Acme/fin/payroll</td>", // the scope's kind, in Turkish
|
||||
"<td>kişisel</td><td>maskelendi</td>",
|
||||
`<td class="num">1.500</td>`, // a count written the Turkish way
|
||||
"<td>04.10.2026 09:05:07</td>", // and a day
|
||||
"<td>engellendi</td>",
|
||||
"<h2>Son olaylar</h2>",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the Turkish guardrails page lacks %q", want)
|
||||
}
|
||||
}
|
||||
for _, english := range []string{"Policies", "What they did", "personal", "blocked"} {
|
||||
if strings.Contains(body, english) {
|
||||
t.Errorf("the Turkish guardrails page still says %q", english)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// The public and personal pages are the first a Turkish operator's customers
|
||||
// see: sign-up, sign-in, the landing page, the claim link and their own
|
||||
// account. Each is drawn here in Turkish, end to end.
|
||||
|
||||
// publicInTurkish is a request whose browser asks for Turkish.
|
||||
func publicInTurkish(r *http.Request) *http.Request {
|
||||
r.Header.Set("Accept-Language", "tr-TR,tr;q=0.9,en;q=0.5")
|
||||
return r
|
||||
}
|
||||
|
||||
// publicRefusingAccounts is an account service whose sign-in fails with err.
|
||||
type publicRefusingAccounts struct {
|
||||
fakeAccounts
|
||||
err error
|
||||
}
|
||||
|
||||
func (a publicRefusingAccounts) SignIn(context.Context, string, string) (string, string, error) {
|
||||
return "", "", a.err
|
||||
}
|
||||
func (publicRefusingAccounts) SignupOpen() bool { return true }
|
||||
|
||||
func TestTheSignInPageSpeaksTurkishWhenAsked(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
NewAccountPages(publicRefusingAccounts{err: account.ErrBadCredentials}, nil, nil, "v0",
|
||||
slog.New(slog.DiscardHandler)).Register(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, publicInTurkish(httptest.NewRequest(http.MethodGet, "/login", nil)))
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{`<html lang="tr">`, "<title>Giriş yap · llmgw</title>", ">Parola<",
|
||||
">E-posta<", `Hesabınız yok mu? <a href="/signup">Kaydolun</a>.`, "bu ağ geçidi hakkında"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the Turkish sign-in page lacks %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "No account?") {
|
||||
t.Error("the Turkish sign-in page still has English on it")
|
||||
}
|
||||
|
||||
// A refused sign-in says why in Turkish: the account service's own
|
||||
// sentence, looked up by the error.
|
||||
var cookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == loginCSRFCookie {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
form := url.Values{"email": {"a@b.c"}, "password": {"x"}, "csrf": {cookie.Value}}
|
||||
req := publicInTurkish(httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if !strings.Contains(rec.Body.String(), "bu e-posta ve parola hiçbir hesapla eşleşmiyor") {
|
||||
t.Errorf("a wrong password is not said in Turkish: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// And in English, as it always was.
|
||||
if got := english.Err(account.ErrBadCredentials); got != account.ErrBadCredentials.Error() {
|
||||
t.Errorf("English says %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSignupPageSaysWhatIsWrongInTurkish(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
NewAccountPages(signupAccounts{err: account.ErrWeakPassword}, nil, nil, "v0",
|
||||
slog.New(slog.DiscardHandler)).Register(mux)
|
||||
form := url.Values{"email": {"a@b.c"}, "password": {"short"}}
|
||||
req := publicInTurkish(httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"<h1>Hesap oluşturun</h1>",
|
||||
fmt.Sprintf("parola en az %d karakter olmalı", account.MinPasswordLen),
|
||||
fmt.Sprintf("En az %d karakter.", account.MinPasswordLen),
|
||||
">Kaydol</button>",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the Turkish signup page lacks %q", want)
|
||||
}
|
||||
}
|
||||
// The English sentence is the account service's own, word for word.
|
||||
if got := english.T("signup.err.weak_password", account.MinPasswordLen); got != account.ErrWeakPassword.Error() {
|
||||
t.Errorf("English says %q, the service %q", got, account.ErrWeakPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheProfilePageShowsTurkishNumbersAndDates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "s.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
budget := int64(50e6)
|
||||
st.SetPersonalDefaults(&store.Limits{Budget: &budget, BudgetPeriod: store.PeriodMonth})
|
||||
if err := st.WriteUsage(ctx, []store.Usage{
|
||||
{At: time.Now(), KeyID: "session:ada", UserID: "ada", Model: "m", ServedModel: "m",
|
||||
PromptTokens: 1200, CompletionTokens: 300, CostMicros: 45e6, Status: 200},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ada := store.User{ID: "ada", Email: "ada@example.com", Name: "Ada", PasswordHash: "!",
|
||||
State: store.UserActive, CreatedAt: time.Now()}
|
||||
if err := st.CreateUser(ctx, ada); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, k, err := store.NewKey("key-tr", "dizüstü", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
made := time.Date(2026, 3, 9, 12, 0, 0, 0, time.UTC)
|
||||
k.CreatedAt = made
|
||||
if err := st.CreateUserKey(ctx, ada.ID, k); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := NewAccountPages(fakeAccounts{user: ada}, st, nil, "v0", slog.New(slog.DiscardHandler))
|
||||
mux := http.NewServeMux()
|
||||
p.Register(mux)
|
||||
req := publicInTurkish(httptest.NewRequest(http.MethodGet, "/profile", nil))
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: "good"})
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
body := rec.Body.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"<h1>Hesabınız</h1>", "Çıkış yap", ">Hesap</a>",
|
||||
"Kullanımınız ve sınırlarınız", "Günlük token",
|
||||
// 1500 tokens, and 45 of 50 units this month, written the Turkish way.
|
||||
">1,5 bin</div>", "token · 30 gün",
|
||||
"Kullandığınız her şeyi kapsayan bütçeniz", "<b>45,00</b> / 50,00 (bu ay)",
|
||||
"aylık 50,00",
|
||||
// The key: made on 9 March, never used, and active, which stays its
|
||||
// class while its word changes.
|
||||
"<td>09.03.2026</td>", "<td>hiçbir zaman</td>", `class="state state-active">etkin<`,
|
||||
"<td>Kişisel</td>", ">İptal et</button>",
|
||||
// The budget bar's width is CSS, and CSS is not Turkish.
|
||||
"width: 90%",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the Turkish profile lacks %q", want)
|
||||
}
|
||||
}
|
||||
for _, word := range []string{"Your use and limits", "this month", "Revoke"} {
|
||||
if strings.Contains(body, word) {
|
||||
t.Errorf("the Turkish profile still says %q", word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheEditorSetupExplainsItselfInTurkish(t *testing.T) {
|
||||
tr := langByCode("tr")
|
||||
c := connectIn(tr, "cline", "https://gw.example", "m", "sk-llmgw-abc")
|
||||
if c.Fields[2].Label != "API anahtarı (API Key)" || c.Fields[0].Value != "OpenAI Compatible" ||
|
||||
!strings.Contains(c.Run, "dişli simgesine") {
|
||||
t.Errorf("cline in Turkish = %+v", c)
|
||||
}
|
||||
x := connectIn(tr, "codex", "https://gw.example", "m", "sk-llmgw-abc")
|
||||
// What is typed or written to a file stays what the tool reads.
|
||||
if x.Run != "LLMGW_API_KEY='sk-llmgw-abc' codex --profile llmgw" || !strings.Contains(x.Files[0].Body, "[profiles.llmgw]") ||
|
||||
!strings.Contains(x.Notes[0], "~/.codex/config.toml") {
|
||||
t.Errorf("codex in Turkish = %+v", x)
|
||||
}
|
||||
if en := connectFor("continue", "https://gw.example", "m", "k"); en.Run != `In Continue, choose the "llmgw" assistant at the top of its panel.` {
|
||||
t.Errorf("English Continue: %q", en.Run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLandingPageSpeaksTurkish(t *testing.T) {
|
||||
h := Landing("v1", true, false, "platform@example.com", "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, publicInTurkish(httptest.NewRequest(http.MethodGet, "http://gw.example/", nil)))
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`<html lang="tr">`, "OpenAI uyumlu tek bir uç nokta", "Üç adımda başlayın",
|
||||
`Anahtar için şuraya başvurun: <a href="mailto:platform@example.com">platform@example.com</a>.`,
|
||||
`<a href="/login">giriş yapın</a>`,
|
||||
// The examples are code, and code is the same in every language.
|
||||
"http://gw.example/v1/chat/completions", "Authorization: Bearer",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the Turkish landing page lacks %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "Three steps") || strings.Contains(body, "for one.") {
|
||||
t.Error("the Turkish landing page still has English on it")
|
||||
}
|
||||
|
||||
// English reads as it did, the contact a link inside its sentence.
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://gw.example/", nil))
|
||||
if !strings.Contains(rec.Body.String(), `Ask <a href="mailto:platform@example.com">platform@example.com</a>`+"\n for one.") {
|
||||
t.Errorf("the English contact sentence changed: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// A contact that is not a link is emphasised, and escaped either way.
|
||||
rec = httptest.NewRecorder()
|
||||
Landing("v1", false, false, "<b>Ayşe</b>", "").ServeHTTP(rec,
|
||||
publicInTurkish(httptest.NewRequest(http.MethodGet, "http://gw.example/", nil)))
|
||||
if !strings.Contains(rec.Body.String(), "<strong><b>Ayşe</b></strong>") {
|
||||
t.Errorf("a plain contact: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheClaimPageSpeaksTurkish(t *testing.T) {
|
||||
f := &fakeClaims{secret: "sk-llmgw-thesecret"}
|
||||
mux := http.NewServeMux()
|
||||
h := Claim(f, "v1", slog.New(slog.DiscardHandler))
|
||||
mux.Handle("GET /claim/{token}", h)
|
||||
mux.Handle("POST /claim/{token}", h)
|
||||
get := func() string {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, publicInTurkish(httptest.NewRequest(http.MethodGet, "/claim/abc", nil)))
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
body := get()
|
||||
if !strings.Contains(body, ">Anahtarımı göster</button>") || !strings.Contains(body, "Son geçerlilik: ") {
|
||||
t.Errorf("the Turkish claim page: %s", body)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, publicInTurkish(httptest.NewRequest(http.MethodPost, "/claim/abc", nil)))
|
||||
if b := rec.Body.String(); !strings.Contains(b, "sk-llmgw-thesecret") || !strings.Contains(b, "parola yöneticisine") {
|
||||
t.Errorf("the revealed key in Turkish: %s", b)
|
||||
}
|
||||
if b := get(); !strings.Contains(b, "bu bağlantı artık geçerli değil") {
|
||||
t.Errorf("a used link in Turkish: %s", b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/quota"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// inTurkish is a signed-in request from someone who chose Türkçe with the
|
||||
// switcher; form, when given, makes it a POST.
|
||||
func inTurkish(t *testing.T, mux *http.ServeMux, path string, form url.Values) string {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
if form != nil {
|
||||
r = httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
r.AddCookie(&http.Cookie{Name: SessionCookie, Value: "good"})
|
||||
r.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
|
||||
r.AddCookie(&http.Cookie{Name: langCookie, Value: "tr"})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s answered %d", path, w.Code)
|
||||
}
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
func TestTheLimitsTabSpeaksTurkishWhenAsked(t *testing.T) {
|
||||
s := oneTeam()
|
||||
rpm := 60
|
||||
s.limits["org:org-1"] = store.Limits{Scope: store.ScopeOrg, ScopeID: "org-1", RPM: &rpm}
|
||||
s.limits["team:team-1"] = store.Limits{
|
||||
Scope: store.ScopeTeam, ScopeID: "team-1",
|
||||
Budget: i64(1_234_500_000), BudgetPeriod: store.PeriodMonth,
|
||||
}
|
||||
big := 120
|
||||
s.limits["project:proj-1"] = store.Limits{Scope: store.ScopeProject, ScopeID: "proj-1", RPM: &big}
|
||||
s.usage = map[store.Scope][]store.ScopeUsage{
|
||||
store.ScopeTeam: {{ID: "team-1", CostMicros: 617_250_000}},
|
||||
}
|
||||
body := inTurkish(t, quotasPage(t, s, true), "/admin/quotas", nil)
|
||||
|
||||
for _, want := range []string{
|
||||
"Sınırlar nasıl birleşir", "İstek/dk", ">ekip<", ">değiştir</a>", ">üye başına</a>",
|
||||
// A budget is written with Turkish separators and a Turkish period,
|
||||
// and so is what was spent against it.
|
||||
"1.234,50", "/ay", "617,25", "%50",
|
||||
// The note under a limit an ancestor holds lower, built in Go.
|
||||
"Scania bunu dakikada 60 istek ile sınırlıyor",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the limits tab in Turkish lacks %q", want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"How limits combine", "caps this at", "1234.50"} {
|
||||
if strings.Contains(body, unwanted) {
|
||||
t.Errorf("the limits tab in Turkish still says %q", unwanted)
|
||||
}
|
||||
}
|
||||
// The form's values stay as the server reads them back, whatever the page
|
||||
// speaks: a Turkish decimal comma would not parse.
|
||||
body = inTurkish(t, quotasPage(t, s, true), "/admin/quotas?edit=team:team-1", nil)
|
||||
if !strings.Contains(body, `name="budget" value="1234.50"`) {
|
||||
t.Error("the budget's form value should be written the C way")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusedLimitIsExplainedInTurkish(t *testing.T) {
|
||||
s := oneTeam()
|
||||
body := inTurkish(t, quotasPage(t, s, true), "/admin/quotas", url.Values{
|
||||
"csrf": {"tok"}, "scope": {"team"}, "id": {"team-1"}, "rpm": {"lots"},
|
||||
})
|
||||
if !strings.Contains(body, "dakikadaki istek sayısı tam sayı olmalı") {
|
||||
t.Errorf("the refusal is not in Turkish: %s", body)
|
||||
}
|
||||
body = inTurkish(t, quotasPage(t, s, true), "/admin/quotas", url.Values{
|
||||
"csrf": {"tok"}, "scope": {"team"}, "id": {"team-1"}, "rpm": {"60"},
|
||||
})
|
||||
if !strings.Contains(body, "ekip team-1: istek/dk 60") {
|
||||
t.Errorf("the change is not reported in Turkish: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCapacitySectionSpeaksTurkishWhenAsked(t *testing.T) {
|
||||
g, l := 8, 16
|
||||
fc := &fakeCapacity{views: []adminapi.QuotaView{{Scope: "team:t1", Name: "Acme / ml",
|
||||
Holds: quota.Amount{GPUs: 6}, Guarantee: store.Resources{GPUs: &g},
|
||||
Limit: store.Resources{GPUs: &l}}}}
|
||||
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
|
||||
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.DiscardHandler))
|
||||
p.SetCapacity(fc)
|
||||
mux := http.NewServeMux()
|
||||
p.RegisterAdminQuotas(mux, oneTeam(), pricedYes{})
|
||||
body := inTurkish(t, mux, "/admin/quotas", nil)
|
||||
for _, want := range []string{"Kapasite", "6 / 8 (en çok 16)", "Güvence altındaki GPU"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the capacity section in Turkish lacks %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUsageReportWritesTurkishNumbers(t *testing.T) {
|
||||
s := &fakeScopeSeries{byGroup: map[store.Grouping][]store.UsageSeries{
|
||||
store.GroupByTeam: {hourly(store.GroupByTeam, "t1", "platform", 3)},
|
||||
store.GroupByUser: {{Grouping: store.GroupByUser}},
|
||||
}}
|
||||
body := inTurkish(t, usagePage(t, s, pricedYes{}), "/admin/usage", nil)
|
||||
// Three requests an hour rising over six hours is 63 requests of 120
|
||||
// tokens each: 7560 tokens, said the Turkish way; and 0.063 spent.
|
||||
for _, want := range []string{"Gruplama", "son 7 gün", ">ekip</a>", "İstekler", "Hatalar",
|
||||
"7,6 bin", "0,06"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the usage report in Turkish lacks %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "last 7 days") || strings.Contains(body, "7.6k") {
|
||||
t.Error("the usage report in Turkish still has English in it")
|
||||
}
|
||||
body = inTurkish(t, usagePage(t, s, pricedNo{}), "/admin/usage?by=user", nil)
|
||||
for _, want := range []string{"sahibi olmayan anahtarlar", "Hiçbir modelin fiyatı yok"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the usage report by person in Turkish lacks %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOrganizationPageSpeaksTurkishWhenAsked(t *testing.T) {
|
||||
body := inTurkish(t, orgPage(t, store.User{ID: "u1", Email: "boss@acme"}), "/org/org-a", nil)
|
||||
for _, want := range []string{"2 kişi", "Kullanım", "Ekipler, projeler ve sınırlar", "Üyeler",
|
||||
">yönetici</b>", "kuruluşun tamamı", "son 7 gün", `lang="tr"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the organization page in Turkish lacks %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "Teams, projects and limits") {
|
||||
t.Error("the organization page in Turkish still has English headings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePageAboutNoOrganizationsSpeaksTurkish(t *testing.T) {
|
||||
p := NewAccountPages(fakeAccounts{user: store.User{ID: "a", IsAdmin: true}}, nil, nil, "v0", slog.New(slog.DiscardHandler))
|
||||
mux := http.NewServeMux()
|
||||
p.RegisterOrgPages(mux, noOrgs{}, nil)
|
||||
body := inTurkish(t, mux, "/org", nil)
|
||||
if !strings.Contains(body, "Bu kümede hiç kuruluş yok") || !strings.Contains(body, "<title>Kuruluşlar · llmgw</title>") {
|
||||
t.Errorf("the page about no organizations is not in Turkish: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// The keys a template looks up: {{t "key"}}, (th "key" .X), {{tn "key" .N}}.
|
||||
var templateKey = regexp.MustCompile(`(?:\{\{-?|\(|\|)\s*(t|th|tn)\s+"([^"]+)"`)
|
||||
|
||||
// The keys Go code looks up: l.T("key"), lang.N("key", n), registerErrorKey(err, "key").
|
||||
var (
|
||||
goKey = regexp.MustCompile(`\.(T|TH|N)\(\s*"([^"]+)"\s*[,)]`)
|
||||
goErrorKey = regexp.MustCompile(`registerErrorKey\([^,]+,\s*"([^"]+)"\)`)
|
||||
goRegisterKey = regexp.MustCompile(`registerKeys\(([^)]*)\)`)
|
||||
goStringLit = regexp.MustCompile(`"([^"]+)"`)
|
||||
)
|
||||
|
||||
// usedKeys is every key the pages and handlers name, with whether it is a
|
||||
// counted one (tn, N), which a language may give as key.other alone.
|
||||
func usedKeys(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
keys := map[string]bool{}
|
||||
err := fs.WalkDir(files, "templates", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, err := fs.ReadFile(files, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range templateKey.FindAllStringSubmatch(string(b), -1) {
|
||||
keys[m[2]] = keys[m[2]] || m[1] == "tn"
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gofiles, _ := filepath.Glob("*.go")
|
||||
for _, name := range gofiles {
|
||||
if strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range goKey.FindAllStringSubmatch(string(b), -1) {
|
||||
keys[m[2]] = keys[m[2]] || m[1] == "N"
|
||||
}
|
||||
for _, m := range goErrorKey.FindAllStringSubmatch(string(b), -1) {
|
||||
keys[m[1]] = keys[m[1]] || false
|
||||
}
|
||||
for _, m := range goRegisterKey.FindAllStringSubmatch(string(b), -1) {
|
||||
for _, s := range goStringLit.FindAllStringSubmatch(m[1], -1) {
|
||||
if strings.HasSuffix(s[1], ".") {
|
||||
continue // the prefix of keys built in a loop, listed by dynamicKeys
|
||||
}
|
||||
keys[s[1]] = keys[s[1]] || false
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the ones built at run time, listed where they are built.
|
||||
for _, k := range dynamicKeys {
|
||||
keys[k] = keys[k] || false
|
||||
}
|
||||
for _, ek := range errorKeys {
|
||||
keys[ek.key] = keys[ek.key] || false
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// A key one language has and another lacks is a sentence that stays English
|
||||
// on the second language's pages, which nobody notices until a customer does.
|
||||
func TestEveryKeyUsedHasTextInEveryLanguage(t *testing.T) {
|
||||
keys := usedKeys(t)
|
||||
if len(keys) < 10 {
|
||||
t.Fatalf("found only %d keys; the scan is broken", len(keys))
|
||||
}
|
||||
for _, l := range languages {
|
||||
var missing []string
|
||||
for k, counted := range keys {
|
||||
if _, ok := l.msgs[k]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := l.msgs[k+".other"]; ok && counted {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, k)
|
||||
}
|
||||
sort.Strings(missing)
|
||||
for _, k := range missing {
|
||||
t.Errorf("%s has no text for %q", l.Code, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fmtVerb finds the arguments a text takes, so a translation that drops or
|
||||
// adds one is caught: Sprintf would print %!s(MISSING) on the page.
|
||||
var fmtVerb = regexp.MustCompile(`%(?:\[\d+\])?[-+# 0]*\d*(?:\.\d+)?[a-zA-Z%]`)
|
||||
|
||||
func verbsOf(s string) []string {
|
||||
var out []string
|
||||
for _, v := range fmtVerb.FindAllString(s, -1) {
|
||||
if v == "%%" {
|
||||
continue
|
||||
}
|
||||
// %[2]s and %s are the same argument kind in a different place.
|
||||
v = regexp.MustCompile(`\[\d+\]`).ReplaceAllString(v, "")
|
||||
out = append(out, v[len(v)-1:])
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Every language says everything English says, with the same arguments, and
|
||||
// nothing English does not — a key left behind by a page that changed is
|
||||
// text nobody can see and somebody will translate.
|
||||
func TestEveryLanguageHasTheSameKeysAsEnglishWithTheSameArguments(t *testing.T) {
|
||||
for _, l := range languages[1:] {
|
||||
for k, en := range english.msgs {
|
||||
if strings.HasPrefix(k, "_meta.") {
|
||||
continue
|
||||
}
|
||||
v, ok := l.msgs[k]
|
||||
if !ok {
|
||||
// A singular is English's; a language without one says the
|
||||
// count with key.other.
|
||||
if strings.HasSuffix(k, ".one") {
|
||||
continue
|
||||
}
|
||||
t.Errorf("%s lacks %q (English: %q)", l.Code, k, en)
|
||||
continue
|
||||
}
|
||||
if !slices.Equal(verbsOf(v), verbsOf(en)) {
|
||||
t.Errorf("%s %q takes %v where English takes %v: %q", l.Code, k, verbsOf(v), verbsOf(en), v)
|
||||
}
|
||||
}
|
||||
for k := range l.msgs {
|
||||
if _, ok := english.msgs[k]; !ok {
|
||||
t.Errorf("%s has %q, which English does not", l.Code, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// templateAction is a template action, comments included.
|
||||
var templateAction = regexp.MustCompile(`(?s)\{\{.*?\}\}`)
|
||||
|
||||
// englishWord is a run of letters in a template's text: after translation a
|
||||
// page's own text is punctuation, figures and what the allow-list names.
|
||||
var englishWord = regexp.MustCompile(`[A-Za-z][A-Za-z'’-]*[A-Za-z]`)
|
||||
|
||||
// untranslatable are words a page may carry in every language: the product,
|
||||
// units, and the names of standards and hardware, which Turkish writes the
|
||||
// same way. A word of prose does not belong here; put it in a catalogue.
|
||||
var untranslatable = map[string]bool{
|
||||
"llmgw": true, "ms": true, "MHz": true, "GB": true, "GiB": true, "MB": true, "TB": true,
|
||||
"Wh": true, "GPU": true, "GPUs": true, "CPU": true, "CPUs": true, "API": true, "URL": true,
|
||||
"CUDA": true, "NVLink": true, "PCIe": true, "ECC": true, "vLLM": true, "SSO": true,
|
||||
"OIDC": true, "SCIM": true, "TLS": true, "HTTP": true, "HTTPS": true, "JSON": true,
|
||||
"YAML": true, "KV": true,
|
||||
}
|
||||
|
||||
// textAttributes are the attributes a person reads.
|
||||
var textAttributes = map[string]bool{
|
||||
"title": true, "placeholder": true, "aria-label": true, "alt": true,
|
||||
"data-confirm": true, "label": true,
|
||||
}
|
||||
|
||||
// skippedElements hold code, commands and scripts, which are the same in
|
||||
// every language; translate="no" marks anything else that is.
|
||||
var skippedElements = map[string]bool{
|
||||
"script": true, "style": true, "pre": true, "code": true, "kbd": true, "samp": true,
|
||||
}
|
||||
|
||||
var voidElements = map[string]bool{
|
||||
"area": true, "base": true, "br": true, "col": true, "embed": true, "hr": true, "img": true,
|
||||
"input": true, "link": true, "meta": true, "source": true, "track": true, "wbr": true,
|
||||
}
|
||||
|
||||
// englishIn reports the English words left in a template's own text and in
|
||||
// the attributes a person reads, with their lines.
|
||||
func englishIn(src string) []string {
|
||||
var found []string
|
||||
lineAt := func(off int) int { return 1 + strings.Count(src[:min(off, len(src))], "\n") }
|
||||
// report notes s when it has a word of prose in it, at the line of that
|
||||
// word: raw is the source s came from, starting at off.
|
||||
report := func(off int, raw, s string) {
|
||||
for _, w := range englishWord.FindAllString(s, -1) {
|
||||
if untranslatable[w] {
|
||||
continue
|
||||
}
|
||||
if i := strings.Index(raw, w); i >= 0 {
|
||||
off += i
|
||||
}
|
||||
found = append(found, fmt.Sprintf("line %d: %s", lineAt(off), strings.Join(strings.Fields(s), " ")))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Literal strings inside actions that read as prose: a printf format or
|
||||
// a dict value with a space and a word in it.
|
||||
for _, loc := range templateAction.FindAllStringIndex(src, -1) {
|
||||
a := src[loc[0]:loc[1]]
|
||||
if strings.HasPrefix(strings.TrimLeft(a, "{-"), "/*") || strings.HasPrefix(strings.TrimLeft(a, "{- "), "/*") {
|
||||
continue
|
||||
}
|
||||
for _, lit := range goStringLit.FindAllStringSubmatchIndex(a, -1) {
|
||||
s := a[lit[2]:lit[3]]
|
||||
if strings.Contains(s, " ") && englishWord.MatchString(s) {
|
||||
report(loc[0]+lit[0], s, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The text and attributes, with every action blanked to spaces of the
|
||||
// same length so offsets still find lines.
|
||||
blank := templateAction.ReplaceAllStringFunc(src, func(a string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '\n' {
|
||||
return '\n'
|
||||
}
|
||||
return ' '
|
||||
}, a)
|
||||
})
|
||||
z := html.NewTokenizer(strings.NewReader(blank))
|
||||
off := 0
|
||||
var skip []string // the element being skipped, once per level of nesting
|
||||
for {
|
||||
tt := z.Next()
|
||||
if tt == html.ErrorToken {
|
||||
break
|
||||
}
|
||||
rawTok := string(z.Raw())
|
||||
tok := z.Token()
|
||||
switch tt {
|
||||
case html.StartTagToken, html.SelfClosingTagToken:
|
||||
name := tok.Data
|
||||
if len(skip) > 0 {
|
||||
if name == skip[0] && tt == html.StartTagToken && !voidElements[name] {
|
||||
skip = append(skip, name)
|
||||
}
|
||||
break
|
||||
}
|
||||
no := skippedElements[name]
|
||||
for _, a := range tok.Attr {
|
||||
if a.Key == "translate" && a.Val == "no" {
|
||||
no = true
|
||||
}
|
||||
if textAttributes[a.Key] {
|
||||
report(off, rawTok, a.Val)
|
||||
}
|
||||
if a.Key == "value" && name == "input" {
|
||||
for _, b := range tok.Attr {
|
||||
if b.Key == "type" && (b.Val == "submit" || b.Val == "button") {
|
||||
report(off, rawTok, a.Val)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if no && tt == html.StartTagToken && !voidElements[name] {
|
||||
skip = append(skip, name)
|
||||
}
|
||||
case html.EndTagToken:
|
||||
if len(skip) > 0 && tok.Data == skip[0] {
|
||||
skip = skip[1:]
|
||||
}
|
||||
case html.TextToken:
|
||||
if len(skip) == 0 {
|
||||
report(off, rawTok, tok.Data)
|
||||
}
|
||||
}
|
||||
off += len(rawTok)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// No page keeps a sentence of its own in English: everything a person reads
|
||||
// goes through the catalogue, so the next page added cannot quietly be
|
||||
// English-only. A word that is the same in every language goes in
|
||||
// untranslatable; a run of code or a name goes in an element with
|
||||
// translate="no", which is what that attribute is for.
|
||||
func TestNoTemplateTextIsLeftInEnglish(t *testing.T) {
|
||||
err := fs.WalkDir(files, "templates", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, err := fs.ReadFile(files, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range englishIn(string(b)) {
|
||||
t.Errorf("%s %s", p, f)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The heuristic itself, on what it must and must not flag.
|
||||
func TestTheEnglishCheckFindsProseAndLeavesCodeAlone(t *testing.T) {
|
||||
src := `<p>{{t "a"}}</p>
|
||||
<p>Sign in</p>
|
||||
<pre>curl -H "Authorization: Bearer x"</pre>
|
||||
<span translate="no">Qwen3 Coder</span>
|
||||
<input placeholder="laptop, ci">
|
||||
<code>llmgw ctl</code> {{.N}} GPUs
|
||||
{{template "x" (dict "Title" (printf "Quota for %s" .Name))}}`
|
||||
got := englishIn(src)
|
||||
want := []string{"line 7: Quota for %s", "line 2: Sign in", "line 5: laptop, ci"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePortalAnswersInTheLanguageAskedFor(t *testing.T) {
|
||||
defer func() { _ = SetDefaultLanguage("") }()
|
||||
req := func(accept, cookie string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if accept != "" {
|
||||
r.Header.Set("Accept-Language", accept)
|
||||
}
|
||||
if cookie != "" {
|
||||
r.AddCookie(&http.Cookie{Name: langCookie, Value: cookie})
|
||||
}
|
||||
return r
|
||||
}
|
||||
for _, c := range []struct {
|
||||
accept, cookie, def, want string
|
||||
}{
|
||||
{"", "", "", "en"},
|
||||
{"tr-TR,tr;q=0.9,en;q=0.8", "", "", "tr"},
|
||||
{"de-DE,tr;q=0.5,en;q=0.4", "", "", "tr"}, // the first one spoken
|
||||
{"en;q=0.3,tr;q=0.9", "", "", "tr"}, // by weight, not order
|
||||
{"tr-TR", "en", "", "en"}, // the choice outranks the browser
|
||||
{"de, fr", "", "tr", "tr"}, // none spoken: the server's default
|
||||
{"", "xx", "tr", "tr"}, // a cookie naming nothing is ignored
|
||||
{"not a header;;", "", "", "en"}, // and so is a broken header
|
||||
{"EN-gb", "", "tr", "en"}, // case and region do not matter
|
||||
{"", "TR", "", "tr"},
|
||||
} {
|
||||
if err := SetDefaultLanguage(c.def); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := langOf(req(c.accept, c.cookie)).Code; got != c.want {
|
||||
t.Errorf("Accept-Language %q, cookie %q, default %q: %s, want %s", c.accept, c.cookie, c.def, got, c.want)
|
||||
}
|
||||
}
|
||||
if err := SetDefaultLanguage("xx"); err == nil || !strings.Contains(err.Error(), "en, tr") {
|
||||
t.Errorf("an unknown default: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurkishNumbersAndDatesAreWrittenTheTurkishWay(t *testing.T) {
|
||||
tr := langByCode("tr")
|
||||
day := time.Date(2026, 10, 4, 9, 5, 0, 0, time.UTC)
|
||||
for _, c := range []struct{ got, want string }{
|
||||
{tr.Num(1234.5, 1), "1.234,5"},
|
||||
{tr.Num(-1234567.25, 2), "-1.234.567,25"},
|
||||
{tr.Num(999, 0), "999"},
|
||||
{tr.Int(1234), "1.234"},
|
||||
{tr.Units(1_500_000), "1,50"},
|
||||
{tr.Compact(1247), "1,2 bin"},
|
||||
{tr.Compact(0.03), "0,03"},
|
||||
{tr.Bytes(1536), "1,5 KiB"},
|
||||
{tr.Latency(8800), "8,8 sn"},
|
||||
{tr.Latency(111_000), "1 dk 51 sn"},
|
||||
{tr.Date(day), "04.10.2026"},
|
||||
{tr.DateTime(day), "04.10.2026 09:05"},
|
||||
{tr.Format(day, "Mon 15:04"), "Paz 09:05"},
|
||||
{tr.Format(day, "2 January"), "4 Ekim"},
|
||||
{tr.Ago(time.Now().Add(-3 * time.Hour)), "3 saat önce"},
|
||||
{tr.Ago(time.Time{}), "hiçbir zaman"},
|
||||
// One is a count like any other in Turkish, not English's singular.
|
||||
{tr.Ago(time.Now().Add(-time.Hour)), "1 saat önce"},
|
||||
// English is as it always was.
|
||||
{english.Num(1234.5, 1), "1234.5"},
|
||||
{english.Compact(1247), "1.2k"},
|
||||
{english.Date(day), "2026-10-04"},
|
||||
{english.Ago(time.Now().Add(-time.Hour)), "1 hour ago"},
|
||||
{english.Ago(time.Now().Add(-3 * time.Hour)), "3 hours ago"},
|
||||
{english.Format(day, "Mon 15:04"), "Sun 09:05"},
|
||||
} {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%q, want %q", c.got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSwitcherRemembersTheChoiceAndGoesBack(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /_lang/{code}", SwitchLanguage)
|
||||
for _, c := range []struct{ code, referer, status, to string }{
|
||||
{"tr", "http://gw.example/profile?tab=keys", "303", "/profile?tab=keys"},
|
||||
{"en", "http://elsewhere.example/phish", "303", "/"}, // never off the site
|
||||
{"tr", "", "303", "/"},
|
||||
{"xx", "http://gw.example/", "404", ""},
|
||||
} {
|
||||
r := httptest.NewRequest(http.MethodGet, "http://gw.example/_lang/"+c.code, nil)
|
||||
if c.referer != "" {
|
||||
r.Header.Set("Referer", c.referer)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, r)
|
||||
if itoa(w.Code) != c.status || w.Header().Get("Location") != c.to {
|
||||
t.Errorf("%s from %q: %d to %q, want %s to %q", c.code, c.referer, w.Code, w.Header().Get("Location"), c.status, c.to)
|
||||
}
|
||||
if c.status == "303" && !strings.Contains(w.Header().Get("Set-Cookie"), langCookie+"="+c.code) {
|
||||
t.Errorf("%s: no cookie: %q", c.code, w.Header().Get("Set-Cookie"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every page offers every language, marks the one in use, and says which it
|
||||
// is to the browser and to screen readers.
|
||||
func TestEveryPageSaysItsLanguageAndOffersTheOthers(t *testing.T) {
|
||||
for _, code := range []string{"en", "tr"} {
|
||||
r := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
r.AddCookie(&http.Cookie{Name: langCookie, Value: code})
|
||||
w := httptest.NewRecorder()
|
||||
render(w, r, "login", loginData{})
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `<html lang="`+code+`">`) || w.Header().Get("Content-Language") != code {
|
||||
t.Errorf("%s: the page does not say its language", code)
|
||||
}
|
||||
if !strings.Contains(body, `href="/_lang/tr"`) && code == "en" || !strings.Contains(body, `href="/_lang/en"`) && code == "tr" {
|
||||
t.Errorf("%s: the other language is not offered", code)
|
||||
}
|
||||
if strings.Contains(body, `href="/_lang/`+code+`"`) {
|
||||
t.Errorf("%s: the language in use is offered as a link", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A catalogue with a key twice keeps one silently in JSON; ours refuses.
|
||||
func TestACatalogueWithAKeyTwiceIsRefused(t *testing.T) {
|
||||
if _, err := decodeCatalogue([]byte(`{"a": "x", "a": "y"}`)); err == nil {
|
||||
t.Error("a key twice was accepted")
|
||||
}
|
||||
if m, err := decodeCatalogue([]byte(`{"a": "x", "b": "y"}`)); err != nil || len(m) != 2 {
|
||||
t.Errorf("%v %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The docs portal's own pages are translated; the documents themselves are
|
||||
// English, and a Turkish reader is told so rather than left to find out.
|
||||
func TestTheDocsPortalSpeaksTurkishAroundEnglishDocuments(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/docs", nil)
|
||||
r.Header.Set("Accept-Language", "tr")
|
||||
w := httptest.NewRecorder()
|
||||
Docs("v1", true)(w, r)
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{`<html lang="tr">`, "llmgw belgeleri", "Tüm belgeler", "yalnızca İngilizcedir", `href="/_lang/en"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the Turkish docs index lacks %q", want)
|
||||
}
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/docs", nil)
|
||||
w = httptest.NewRecorder()
|
||||
Docs("v1", true)(w, r)
|
||||
if strings.Contains(w.Body.String(), "İngilizce") || !strings.Contains(w.Body.String(), "Every document") {
|
||||
t.Error("the English docs index is not as it was")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,6 +18,9 @@ type LandingData struct {
|
||||
SignupOpen bool
|
||||
Contact string
|
||||
ContactHref string
|
||||
// ContactHTML is the contact as the page shows it, a link when it is
|
||||
// one, for the sentence that names it to place.
|
||||
ContactHTML template.HTML
|
||||
Notice string
|
||||
Version string
|
||||
Now string
|
||||
@@ -39,12 +43,13 @@ func Landing(version string, authEnabled, signupOpen bool, contact, notice strin
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
render(w, "landing", LandingData{
|
||||
render(w, r, "landing", LandingData{
|
||||
BaseURL: baseURL(r),
|
||||
AuthEnabled: authEnabled,
|
||||
SignupOpen: signupOpen,
|
||||
Contact: text,
|
||||
ContactHref: href,
|
||||
ContactHTML: contactMarkup(text, href),
|
||||
Notice: notice,
|
||||
Version: version,
|
||||
Now: time.Now().UTC().Format(time.RFC3339),
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"about.meta.description": "llmgw serves models on your own GPUs behind one OpenAI-compatible API.",
|
||||
"about.home_label": "llmgw home",
|
||||
"about.nav.label": "Sections",
|
||||
"about.nav.what": "What it does",
|
||||
"about.nav.fabrics": "Fabrics",
|
||||
"about.nav.install": "Install",
|
||||
"about.nav.proxy": "Reverse proxy",
|
||||
"about.nav.download": "Download",
|
||||
"about.nav.source": "Source",
|
||||
"about.nav.gateway": "This gateway",
|
||||
|
||||
"about.hero.eyebrow": "Self-hosted LLM gateway · %s",
|
||||
"about.hero.title": "One endpoint in front of <em>your own GPUs</em>.",
|
||||
"about.hero.lede": "llmgw runs vLLM, SGLang and llama.cpp across your servers and serves them behind one OpenAI-compatible API. It knows which GPUs share an NVLink domain or an InfiniBand fabric, and a model split across machines never straddles two.",
|
||||
"about.hero.download": "Download",
|
||||
"about.hero.guide": "Install guide",
|
||||
"about.hero.oneline_label": "Set up a control plane",
|
||||
"about.copy": "Copy",
|
||||
"about.copied": "Copied",
|
||||
"about.selected": "Selected",
|
||||
"about.schem.title": "Clients reach one llmgw address. llmgw places a model's shards on GPUs inside one fabric at a time.",
|
||||
"about.schem.editor": "editor agent",
|
||||
"about.schem.caption": "A replica spanning nodes goes into one NVLink domain if one has room, otherwise onto one InfiniBand fabric. Two fabrics that cannot see each other are never bridged: the all-reduce would hang.",
|
||||
|
||||
"about.what.title": "What it does",
|
||||
"about.what.intro": "One static binary. It runs as the control plane, as the agent on every GPU node, and as the weights mirror.",
|
||||
"about.what.api.title": "OpenAI API, your hardware",
|
||||
"about.what.api.text": "Point any OpenAI client at it. Only the base URL changes. Streaming, tool calls, <code>/v1/models</code> and <code>/v1/responses</code> work as your client expects.",
|
||||
"about.what.placement.title": "Placement that knows the fabric",
|
||||
"about.what.placement.text": "Agents report GPU class, memory, NVLink peers and InfiniBand ports. Replicas go where they fit, including ones larger than one machine, on the GPUs with the most NVLink pairs.",
|
||||
"about.what.ondemand.title": "Models load when asked for",
|
||||
"about.what.ondemand.text": "Keep more models in the catalog than your GPUs hold. The first request starts one, the chat page shows it loading, and it stops again after the idle timeout.",
|
||||
"about.what.keys.title": "Keys, budgets, rate limits",
|
||||
"about.what.keys.text": "Organisations, teams and projects, each with limits. Priority bands decide who waits when the cluster is full. Managers see their organisation's spend; people see their own.",
|
||||
"about.what.editors.title": "Editors connect from the account page",
|
||||
"about.what.editors.text": "Pick Continue, Codex or Cline and a model. The page makes a key and hands over the config, filled in, or a one-line command that writes it.",
|
||||
"about.what.chat.title": "A chat page for quick questions",
|
||||
"about.what.chat.text": "Markdown and maths rendered, stop mid-reply, tokens per second and time to first token, a system prompt and a temperature. The gateway keeps no conversations.",
|
||||
"about.what.mirror.title": "A mirror you browse",
|
||||
"about.what.mirror.text": "Explore what is trending on Hugging Face, read a model card under its row, and download once for the whole cluster. Only what your nodes can run is shown by default.",
|
||||
"about.what.engines.title": "Nodes install their own engines",
|
||||
"about.what.engines.text": "llama.cpp on every node; vLLM on every GPU node that can run it, from PyPI with every file hash-checked. A node that cannot run it says why.",
|
||||
"about.what.upgrades.title": "Upgrades that keep models running",
|
||||
"about.what.upgrades.text": "Upgrade the control plane, then the agents. A restarted agent adopts the engines it was running instead of reloading them, so replicas stay up throughout.",
|
||||
"about.what.dashboard.title": "A dashboard with nothing to set up",
|
||||
"about.what.dashboard.text": "<code>/admin</code> shows capacity per GPU class, CPU and memory per node, who is using what, each node's page and logs. It fits a phone. Prometheus is optional.",
|
||||
|
||||
"about.fabrics.title": "InfiniBand and NVLink, several of each",
|
||||
"about.fabrics.intro": "A site may have independent InfiniBand fabrics, eight-GPU HGX boards and NVL72 racks side by side. llmgw tells them apart by what the adapters report, not by what a config file calls them.",
|
||||
"about.fabrics.guide": "Fabrics and NVLink guide",
|
||||
"about.fabrics.order_label": "Where a replica spanning nodes is placed, in order",
|
||||
"about.fabrics.nvlink.title": "One NVLink domain",
|
||||
"about.fabrics.nvlink.text": "NVL72 or NVLink Switch. Every card joined, IMEX up. Shards get <code>NCCL_MNNVL_ENABLE=1</code>.",
|
||||
"about.fabrics.ib.title": "One InfiniBand or RoCE fabric",
|
||||
"about.fabrics.ib.text": "Nodes under one leaf first, nodes with a degraded cable last.",
|
||||
"about.fabrics.never.title": "Never across two",
|
||||
"about.fabrics.never.text": "Two fabrics given one name are placed as two, and the cluster page says so.",
|
||||
"about.fabrics.doctor": "Check a node in one command",
|
||||
"about.fabrics.doctor_label": "Example output of llmgw doctor",
|
||||
"about.fabrics.sm.title": "Subnet managers, watched",
|
||||
"about.fabrics.sm.text": "Master, priority, standbys and failovers per fabric. A fabric with no standby is flagged. llmgw does not run one.",
|
||||
"about.fabrics.map.title": "A map of each fabric",
|
||||
"about.fabrics.map.text": "<code>/admin/fabrics</code> draws switches in tiers, cables coloured by rate, degraded ones in red, and live traffic on your adapters.",
|
||||
"about.fabrics.racks.title": "NVLink domains as racks",
|
||||
"about.fabrics.racks.text": "<code>/admin/nvlink</code> shows which trays are joined, whose IMEX is down, which link runs degraded.",
|
||||
"about.fabrics.ports.title": "Ports re-read every heartbeat",
|
||||
"about.fabrics.ports.text": "A port that goes down takes its fabric off the node within seconds. Counters and rates are exported to Prometheus.",
|
||||
|
||||
"about.install.title": "From nothing to a first request",
|
||||
"about.install.intro": "Debian, Ubuntu or RHEL 10. NVIDIA drivers and the InfiniBand stack are yours to install; llmgw does the rest.",
|
||||
"about.install.guide": "<a href=\"https://gitea.mynodes.xyz/ilker/llmgw/src/branch/main/docs/install.md\">The full guide</a> covers the mirror, GPUs and what to do when something does not work.",
|
||||
"about.install.binary.title": "Put the binary on every machine",
|
||||
"about.install.binary.text": "Static, no dependencies, the same file everywhere.",
|
||||
"about.install.server.title": "Set up the control plane",
|
||||
"about.install.server.text": "Creates the user, the certificate authority, the certificate, <code>server.yaml</code> and your administrator account, then starts the service. Safe to run again. Add <code>--host</code> for any name nodes use that the machine cannot see itself.",
|
||||
"about.install.node.title": "Add a node",
|
||||
"about.install.node.text": "From the control plane, which holds the authority. The node gets its certificate, the binary and the agent, and installs its engine by itself.",
|
||||
"about.install.node.output_label": "Example output of llmgw ctl nodes",
|
||||
"about.install.model.title": "Add a model and send a request",
|
||||
"about.install.model.text": "No restart. If it does not place, <code>llmgw ctl explain-placement</code> names every node and why it was turned down.",
|
||||
|
||||
"about.proxy.title": "Behind a reverse proxy",
|
||||
"about.proxy.intro": "Port 8080 is plain HTTP. Put it behind Caddy or nginx with a public certificate. Only 8080 goes through the proxy: agents reach 7443 directly, because it is mutual TLS with the cluster's own authority.",
|
||||
"about.proxy.loopback": "Bind llmgw to loopback when the proxy is on the same machine: <code>listen: \"127.0.0.1:8080\"</code>.",
|
||||
"about.proxy.caddy": "Obtains and renews its own certificate. Replace <code>llm.example.com</code>.",
|
||||
"about.proxy.nginx": "Certificate from <code>certbot --nginx</code>. Works on nginx 1.18 and later.",
|
||||
"about.proxy.stream.title": "Streams are not buffered",
|
||||
"about.proxy.stream.text": "Tokens reach the client as they are generated, not in one burst at the end.",
|
||||
"about.proxy.timeout.title": "The read timeout outlasts the model",
|
||||
"about.proxy.timeout.text": "nginx's 60-second default turns a slow first token into a 504.",
|
||||
"about.proxy.headers.title": "Forwarded headers are set, not passed on",
|
||||
"about.proxy.headers.text": "llmgw marks session cookies Secure from <code>X-Forwarded-Proto</code> and shows <code>X-Forwarded-Host</code> as the base URL.",
|
||||
"about.proxy.body.title": "Body limit matches the router",
|
||||
"about.proxy.body.text": "32 MiB, so an oversized request is refused before it is uploaded.",
|
||||
|
||||
"about.download.title": "Download",
|
||||
"about.download.intro": "Built by CI from the tag and checked against <code>checksums.txt</code>. Every release is on the <a href=\"https://gitea.mynodes.xyz/ilker/llmgw/releases\">releases page</a> with its changelog.",
|
||||
"about.download.released": "Released %s",
|
||||
"about.download.col.for": "For",
|
||||
"about.download.col.file": "File",
|
||||
"about.download.col.notes": "Notes",
|
||||
"about.download.static_any": "Static. Runs on any libc.",
|
||||
"about.download.static": "Static.",
|
||||
"about.download.nvml": "Sees NVLink topology through NVML. For PCIe boxes with NVLink bridges. Needs the driver.",
|
||||
"about.download.mac_arm": "macOS Apple silicon",
|
||||
"about.download.mac_note": "For <code>llmgw ctl</code> and trying it on a laptop.",
|
||||
"about.download.proxy_note": "Reverse proxy for port 8080.",
|
||||
"about.download.checksums": "Checksums",
|
||||
"about.download.checksums_note": "SHA-256 of every file above.",
|
||||
"about.download.dev": "This is a development build (%s). Take a release from the <a href=\"https://gitea.mynodes.xyz/ilker/llmgw/releases\">releases page</a>, where every file is listed with its checksum.",
|
||||
|
||||
"about.status.title": "Where it stands",
|
||||
"about.status.intro": "Pre-1.0: configuration formats may change between minor versions. This is what has run on real hardware, and what has not yet.",
|
||||
"about.status.proven": "Run on real clusters",
|
||||
"about.status.proven.single": "Single-node replicas on real GPUs and on CPU",
|
||||
"about.status.proven.ondemand": "On-demand loading and idle shutdown",
|
||||
"about.status.proven.keys": "Keys, budgets, rate limits and priority bands",
|
||||
"about.status.proven.upgrades": "Rolling upgrades with no dropped replicas",
|
||||
"about.status.proven.stores": "SQLite and PostgreSQL stores",
|
||||
"about.status.proven.chat": "Chat, editor setup and the Responses API against a six-node test cluster",
|
||||
"about.status.open": "Not yet proven",
|
||||
"about.status.open.fabrics": "InfiniBand and NVLink on a real site: written and tested against the real output of <code>sminfo</code>, <code>ibnetdiscover</code> and <code>nvidia-smi</code>, but not yet run on the hardware. Run <code>llmgw doctor --json</code> on a few nodes first.",
|
||||
"about.status.open.planes": "Two control planes over long periods",
|
||||
"about.status.open.weights": "Nodes do not share downloaded weights; the mirror keeps each download local",
|
||||
|
||||
"about.footer.changelog": "Changelog",
|
||||
"about.footer.reference": "Configuration reference"
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"signup.title": "Sign up · llmgw",
|
||||
"signup.heading": "Create an account",
|
||||
"signup.sub": "An operator approves it before it can be used.",
|
||||
"signup.thanks": "Thank you. Your account is waiting for approval.",
|
||||
"signup.no_email": "You will not get an email from this gateway — it does not send any.\nTell whoever you asked for access that you have signed up.",
|
||||
"signup.sign_in_once_approved": "<a href=\"/login\">Sign in</a> once you have been approved.",
|
||||
"signup.already_had": "If this address already had an account, nothing new was made:\n<a href=\"/login\">sign in</a> with it, or, if you have forgotten its password, ask\nan operator to reset it.",
|
||||
"signup.email": "Email",
|
||||
"signup.name": "Name",
|
||||
"signup.password": "Password",
|
||||
"signup.password_rule": "At least %d characters. Length is the only rule —\n a long phrase beats a short one with punctuation in it.",
|
||||
"signup.submit": "Sign up",
|
||||
"signup.have_account": "Already have an account? <a href=\"/login\">Sign in</a>.",
|
||||
"signup.err.weak_password": "a password must be at least %d characters",
|
||||
"signup.err.invalid_email": "that does not look like an email address",
|
||||
"signup.err.internal": "something went wrong on our side, and your account was not created. Try again later; if it keeps happening, give an operator this reference: %s",
|
||||
|
||||
"login.title": "Sign in · llmgw",
|
||||
"login.heading": "Sign in",
|
||||
"login.email": "Email",
|
||||
"login.password": "Password",
|
||||
"login.submit": "Sign in",
|
||||
"login.with_sso": "Sign in with %s",
|
||||
"login.no_account": "No account? <a href=\"/signup\">Sign up</a>.",
|
||||
"login.err.form_expired": "the form had expired; sign in again",
|
||||
"login.err.bad_credentials": "that email and password do not match an account",
|
||||
"login.err.not_approved": "this account is waiting for approval",
|
||||
"login.err.suspended": "this account has been suspended",
|
||||
"login.err.too_many_tries": "too many attempts; try again shortly",
|
||||
"login.err.busy": "too many sign-ins at once; try again in a moment",
|
||||
"login.err.try_again": "something went wrong; try again",
|
||||
"login.err.passwords_off": "this gateway signs people in with %s, not passwords",
|
||||
"login.err.rationed": "too many attempts from this address; wait a minute",
|
||||
|
||||
"sso.default_label": "single sign-on",
|
||||
"sso.err.no_answer": "the sign-in service did not answer; try again shortly",
|
||||
"sso.err.failed": "signing in through the provider did not work; start again",
|
||||
"sso.err.no_email": "the provider gave no email address; ask it to send the email claim",
|
||||
"sso.err.unverified": "the provider has not verified %s",
|
||||
"sso.err.no_account": "there is no account for %s on this gateway; ask an administrator for one",
|
||||
"sso.err.pending": "the account for %s is pending",
|
||||
"sso.err.suspended": "the account for %s is suspended",
|
||||
"sso.err.state": "the account for %s is %s",
|
||||
|
||||
"profile.title": "Account · llmgw",
|
||||
"profile.heading": "Your account",
|
||||
"profile.new_key": "Your new key",
|
||||
"profile.shown_once": "This is shown once. Nothing can produce it again — only its hash is kept.",
|
||||
"profile.store_it": "Put it in a password manager now.",
|
||||
"profile.copy_prompt": "Copy",
|
||||
|
||||
"connect.ready": "%s is ready to connect",
|
||||
"connect.shown_once": "A key was made for it, <strong>shown this once</strong> — it is in everything below. Nothing can produce it again.",
|
||||
"connect.one_command": "One command",
|
||||
"connect.command_note": "Writes the file below, keeping a copy of anything it replaces.",
|
||||
"connect.command_note_codex": "Writes the file below into your Codex configuration, keeping a copy of anything it replaces.",
|
||||
"connect.by_hand": "Or by hand: <code>%s</code>",
|
||||
"connect.fields": "Settings to fill in",
|
||||
"connect.then": "Then",
|
||||
"connect.heading": "Connect your editor",
|
||||
"connect.intro": "A key of its own for the tool, and its configuration with the key, this gateway and a model\nalready in it. Revoke the key below to disconnect it.",
|
||||
"connect.tool": "Tool",
|
||||
"connect.model": "Model",
|
||||
"connect.model_placeholder": "a model the gateway serves",
|
||||
"connect.spends_against": "Spends against",
|
||||
"connect.submit": "Make a key and the configuration",
|
||||
"connect.field.provider": "API Provider",
|
||||
"connect.field.base_url": "Base URL",
|
||||
"connect.field.key": "API Key",
|
||||
"connect.field.model": "Model ID",
|
||||
"connect.run.continue": "In Continue, choose the \"llmgw\" assistant at the top of its panel.",
|
||||
"connect.note.codex_blocks": "The command adds the two blocks above to ~/.codex/config.toml, replacing any it added before, and keeps a copy of the file as it was.",
|
||||
"connect.note.codex_env": "Put export LLMGW_API_KEY=… in your shell's profile to leave it out of the command line.",
|
||||
"connect.run.cline": "Cline keeps these in VS Code's own secret storage, so they are set in its settings: open Cline, the gear, and fill them in.",
|
||||
"connect.err.choose": "choose a tool and a model",
|
||||
|
||||
"profile.create_key": "Create a key",
|
||||
"profile.key_for": "What is it for?",
|
||||
"profile.key_for_placeholder": "laptop, ci, notebook",
|
||||
"profile.spends_against": "Spends against",
|
||||
"profile.create": "Create",
|
||||
"profile.no_project": "You are in no project yet, and personal keys are off here.\n Ask an administrator to add you to one.",
|
||||
"profile.keys": "Keys",
|
||||
"profile.col.name": "Name",
|
||||
"profile.col.key": "Key",
|
||||
"profile.col.spends_against": "Spends against",
|
||||
"profile.col.created": "Created",
|
||||
"profile.col.last_used": "Last used",
|
||||
"profile.col.state": "State",
|
||||
"profile.left_project": "a project you have left",
|
||||
"profile.personal": "Personal",
|
||||
"profile.state.active": "active",
|
||||
"profile.state.revoked": "revoked",
|
||||
"profile.state.expired": "expired",
|
||||
"profile.revoke": "Revoke",
|
||||
"profile.revoke_note": "Revoking takes effect within the gateway's cache window, up to a\nminute. To rotate: create the new key, switch over, wait for \"last used\" on the\nold one to stop moving, then revoke it.",
|
||||
"profile.no_keys": "You have no keys yet.",
|
||||
"profile.where": "Where you are",
|
||||
"profile.scope.org": "org",
|
||||
"profile.scope.team": "team",
|
||||
"profile.scope.project": "project",
|
||||
"profile.no_memberships": "You are not a member of any organization, team or project.",
|
||||
|
||||
"selfuse.heading": "Your use and limits",
|
||||
"selfuse.requests_30d": "requests · 30 days",
|
||||
"selfuse.tokens_30d": "tokens · 30 days",
|
||||
"selfuse.spend_30d": "spend · 30 days",
|
||||
"selfuse.tokens_per_day": "Tokens per day",
|
||||
"selfuse.chart_unit": "tokens",
|
||||
"selfuse.place_use": "— %s requests, %s tokens in 30 days",
|
||||
"selfuse.budget_of": "<b>%s</b> of %s %s",
|
||||
"selfuse.pool.user": "Your budget, across everything you use",
|
||||
"selfuse.pool.project_member": "Your budget in this project",
|
||||
"selfuse.pool.team_member": "Your budget in this team",
|
||||
"selfuse.pool.org_member": "Your budget in this organization",
|
||||
"selfuse.pool.project": "The project's budget, shared with its members",
|
||||
"selfuse.pool.team": "The team's budget, shared across its projects",
|
||||
"selfuse.pool.org": "The organization's budget, shared across it",
|
||||
"selfuse.period.day": "today",
|
||||
"selfuse.period.month": "this month",
|
||||
"selfuse.period.none": "in total",
|
||||
"selfuse.period.other": "this %s",
|
||||
"selfuse.col.limit": "Limit",
|
||||
"selfuse.col.value": "Value",
|
||||
"selfuse.limit.rpm": "Requests per minute",
|
||||
"selfuse.limit.tpm": "Tokens per minute",
|
||||
"selfuse.limit.budget": "Budget",
|
||||
"selfuse.limit.budget_day": "%s per day",
|
||||
"selfuse.limit.budget_month": "%s per month",
|
||||
"selfuse.limit.budget_none": "%s in total",
|
||||
"selfuse.limit.budget_other": "%s per %s",
|
||||
"selfuse.limit.models": "Models",
|
||||
"selfuse.limit.guard": "Guardrails",
|
||||
"selfuse.limit.keep_nothing": "%s — nothing is kept: no conversations, notes or outside models",
|
||||
"selfuse.no_limits": "No limits here.",
|
||||
"selfuse.nowhere": "Nowhere to spend yet.",
|
||||
"selfuse.strictest": "Everything that applies, from the organization down to you: the\nstrictest value wins.",
|
||||
|
||||
"profile.password": "Password",
|
||||
"profile.password_changed": "Your password was changed. Any other browser you were\nsigned in on has been signed out.",
|
||||
"profile.current_password": "Current password",
|
||||
"profile.new_password": "New password",
|
||||
"profile.change_password": "Change password",
|
||||
"profile.password_note": "Changing it signs out every other browser, which is the point if\nyou are changing it because something went wrong.",
|
||||
"profile.err.personal_off": "personal keys are off on this gateway; choose a project",
|
||||
"profile.err.try_again": "something went wrong; try again",
|
||||
"profile.err.not_member": "you are not a member of that project",
|
||||
"profile.err.wrong_password": "that is not your current password",
|
||||
"profile.err.same_password": "that is the password you already have",
|
||||
"connect.own_code": "From your own code: the official OpenAI and Anthropic SDKs work as they are —\nthe <a href=\"/docs/clients\">client guide</a> shows how, in Python, TypeScript, Go and curl."
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"chart.peak_unit": "peak %s %s",
|
||||
"chart.nothing_recorded": "nothing recorded",
|
||||
"chart.hour_label": "%s — %s %s",
|
||||
"chart.nothing_yet": "Nothing recorded yet.",
|
||||
"chart.trend": "recent trend",
|
||||
"chart.peak": "peak %s",
|
||||
"chart.other": "other",
|
||||
"chart.axis_ms": "%sms",
|
||||
"chart.axis_s": "%ss",
|
||||
"chart.window.hour": "hour",
|
||||
"chart.window.hours.one": "1 hour",
|
||||
"chart.window.hours.other": "%d hours",
|
||||
"chart.window.minutes.one": "1 minute",
|
||||
"chart.window.minutes.other": "%d minutes"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"chat.title": "Chat",
|
||||
"chat.context.using": "Using",
|
||||
"chat.context.switch": "switch",
|
||||
"chat.model": "Model",
|
||||
"chat.model.simulated": "(simulated)",
|
||||
"chat.model.not_loaded": "(not loaded)",
|
||||
"chat.new": "New conversation",
|
||||
"chat.settings": "Settings",
|
||||
"chat.settings.system": "System prompt",
|
||||
"chat.settings.system_hint": "how the model should behave, sent before every turn",
|
||||
"chat.settings.system_placeholder": "You are a concise assistant. Answer in the language of the question.",
|
||||
"chat.settings.temperature": "Temperature",
|
||||
"chat.settings.temperature_hint": "lower is steadier, higher more varied",
|
||||
"chat.note.simulated": "This model is simulated: it exercises scheduling and reports timings, and its replies are placeholder text, not inference.",
|
||||
"chat.note.on_demand": "This model is not running. The first message loads it onto a node — fetching its weights and starting the engine, minutes for a large model — and is sent once it is up.",
|
||||
"chat.input_placeholder": "Ask something…",
|
||||
"chat.send": "Send",
|
||||
"chat.kept": "The conversation is kept in this browser, so a reload does not lose it; \"New conversation\" clears it. Nothing of it is stored on the gateway — what is recorded there is the same usage any request records: tokens, cost and latency.",
|
||||
"chat.empty": "No models are available to you right now.",
|
||||
"chat.empty_why": "Either nothing is placed yet, or your project's allowlist does not include anything that is. <code>/admin</code> shows the cluster if you have access to it.",
|
||||
|
||||
"chat.js.preparing": "Preparing…",
|
||||
"chat.js.download_failed": "download failed",
|
||||
"chat.js.stage.waiting": "Waiting for a node with room",
|
||||
"chat.js.stage.downloading": "Downloading weights",
|
||||
"chat.js.stage.starting": "Starting the engine",
|
||||
"chat.js.stage.ready": "Ready",
|
||||
"chat.js.of": "{done} of {total}",
|
||||
"chat.js.could_not_start": "the model could not be started",
|
||||
"chat.js.tokens": "{n} tokens",
|
||||
"chat.js.tok_per_s": "{n} tok/s",
|
||||
"chat.js.first_token": "first token {n} s",
|
||||
"chat.js.seconds": "{n} s",
|
||||
"chat.js.stopped": "stopped",
|
||||
"chat.js.stop": "Stop",
|
||||
"chat.js.refused": "Refused by the gateway's policy{policy}: {message}",
|
||||
"chat.js.download_zip": "Download as .zip",
|
||||
"chat.js.stopped_by_policy": "Stopped by the gateway's policy{policy}.",
|
||||
"chat.js.cut_off": "Cut off at the length limit — ask it to continue.",
|
||||
"chat.js.stopped_by_policy_empty": "(stopped by the gateway's policy{policy} before anything was shown)",
|
||||
"chat.js.stopped_empty": "(stopped before anything came back)",
|
||||
"chat.js.no_content": "(no content came back)",
|
||||
|
||||
"download.err.read": "could not read the request",
|
||||
"download.err.no_code": "there is no code in that reply to download"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"claim.title": "Your llmgw key",
|
||||
"claim.heading": "Your API key",
|
||||
"claim.shown_once": "This is shown once. Nothing can produce it again — only its hash was kept.",
|
||||
"claim.store_it": "Store it in a password manager now, then close this page.",
|
||||
"claim.using_it": "Using it",
|
||||
"claim.replace_placeholder": "Replace <model-name> with a name from\n <code>GET %s/v1/models</code>, which needs your key. Sending the\n placeholder unchanged is answered with the list of models that are available.",
|
||||
"claim.endpoints": "<code>GET %[1]s/v1/models</code> lists what this key may use.\n<code>GET %[1]s/me</code> shows its limits and what it has spent.",
|
||||
"claim.ask_again": "A link works once and then expires. Ask whoever sent it for a new one.",
|
||||
"claim.issued": "A key was issued for you. Revealing it uses up this link, and the key is\nshown only this once.",
|
||||
"claim.reveal": "Reveal my key",
|
||||
"claim.expires": "Expires %s.",
|
||||
"claim.err.claimed": "this link is no longer valid"
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"_meta.name": "English",
|
||||
"_meta.decimal": ".",
|
||||
"_meta.group": "",
|
||||
"_meta.date": "2006-01-02",
|
||||
"_meta.datetime": "2006-01-02 15:04",
|
||||
|
||||
"lang.switch": "Language",
|
||||
|
||||
"num.thousands": "%sk",
|
||||
"num.millions": "%sM",
|
||||
"num.billions": "%sB",
|
||||
|
||||
"unit.ms": "%s ms",
|
||||
"unit.s": "%s s",
|
||||
"unit.min_s": "%dm %02ds",
|
||||
"unit.age_s": "%ds",
|
||||
"unit.age_m": "%dm",
|
||||
"unit.age_hm": "%dh%02dm",
|
||||
"unit.age_d": "%dd",
|
||||
|
||||
"time.never": "never",
|
||||
"time.just_now": "just now",
|
||||
"time.minutes_ago.one": "1 minute ago",
|
||||
"time.minutes_ago.other": "%d minutes ago",
|
||||
"time.hours_ago.one": "1 hour ago",
|
||||
"time.hours_ago.other": "%d hours ago",
|
||||
"time.days_ago.one": "1 day ago",
|
||||
"time.days_ago.other": "%d days ago",
|
||||
"time.weeks_ago.one": "1 week ago",
|
||||
"time.weeks_ago.other": "%d weeks ago",
|
||||
"time.months_ago.one": "1 month ago",
|
||||
"time.months_ago.other": "%d months ago",
|
||||
|
||||
"time.weekday.sun": "Sunday",
|
||||
"time.weekday.mon": "Monday",
|
||||
"time.weekday.tue": "Tuesday",
|
||||
"time.weekday.wed": "Wednesday",
|
||||
"time.weekday.thu": "Thursday",
|
||||
"time.weekday.fri": "Friday",
|
||||
"time.weekday.sat": "Saturday",
|
||||
"time.wd.sun": "Sun",
|
||||
"time.wd.mon": "Mon",
|
||||
"time.wd.tue": "Tue",
|
||||
"time.wd.wed": "Wed",
|
||||
"time.wd.thu": "Thu",
|
||||
"time.wd.fri": "Fri",
|
||||
"time.wd.sat": "Sat",
|
||||
"time.month.jan": "January",
|
||||
"time.month.feb": "February",
|
||||
"time.month.mar": "March",
|
||||
"time.month.apr": "April",
|
||||
"time.month.may": "May",
|
||||
"time.month.jun": "June",
|
||||
"time.month.jul": "July",
|
||||
"time.month.aug": "August",
|
||||
"time.month.sep": "September",
|
||||
"time.month.oct": "October",
|
||||
"time.month.nov": "November",
|
||||
"time.month.dec": "December",
|
||||
"time.mon.jan": "Jan",
|
||||
"time.mon.feb": "Feb",
|
||||
"time.mon.mar": "Mar",
|
||||
"time.mon.apr": "Apr",
|
||||
"time.mon.may": "May",
|
||||
"time.mon.jun": "Jun",
|
||||
"time.mon.jul": "Jul",
|
||||
"time.mon.aug": "Aug",
|
||||
"time.mon.sep": "Sep",
|
||||
"time.mon.oct": "Oct",
|
||||
"time.mon.nov": "Nov",
|
||||
"time.mon.dec": "Dec",
|
||||
|
||||
"engine.build.auto": "auto: what the hardware runs",
|
||||
"engine.build.none": "none",
|
||||
|
||||
"common.copy": "copy",
|
||||
"common.copied": "copied",
|
||||
"common.download": "download",
|
||||
"common.save": "Save",
|
||||
"common.cancel": "Cancel",
|
||||
"common.remove": "Remove",
|
||||
"common.delete": "Delete",
|
||||
"common.add": "Add",
|
||||
"common.yes": "yes",
|
||||
"common.no": "no",
|
||||
"common.none": "none",
|
||||
"common.about_gateway": "about this gateway",
|
||||
"common.refreshes_every": "refreshes every %ds",
|
||||
"common.template_error": "The page could not be drawn."
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"dash.title": "cluster",
|
||||
"dash.h1": "Cluster",
|
||||
"dash.sub": "%d nodes · %d of %d replicas ready · %d models",
|
||||
"dash.nav.metrics": "metrics",
|
||||
|
||||
"dash.tile.requests": "requests · 24h",
|
||||
"dash.tile.tokens": "tokens · 24h",
|
||||
"dash.tile.errors": "errors · 24h",
|
||||
"dash.tile.spend": "spend · 24h",
|
||||
|
||||
"dash.capacity": "Capacity",
|
||||
"dash.cap.class": "Class",
|
||||
"dash.cap.in_use": "In use",
|
||||
"dash.cap.free": "Free",
|
||||
"dash.cap.faulty": "Faulty",
|
||||
"dash.cap.usage": "Usage",
|
||||
"dash.cpu.ready": "nodes ready · CPU only",
|
||||
"dash.cpu.memory": "memory",
|
||||
"dash.cpu.disk_of": "%s of %s GB",
|
||||
"dash.cpu.disk_free": "free for weights",
|
||||
"dash.no_nodes": "No nodes have registered.",
|
||||
|
||||
"dash.gpumap.title": "GPUs",
|
||||
"dash.gpumap.by": "by",
|
||||
"dash.gpumap.mode.util": "busy",
|
||||
"dash.gpumap.mode.memory": "memory",
|
||||
"dash.gpumap.mode.temp": "temperature",
|
||||
"dash.gpumap.legend.temp": "Blue is cool, red is 90 °C and over.",
|
||||
"dash.gpumap.legend.memory": "Pale is empty, dark is full. Engines reserve most of a card's memory when they start.",
|
||||
"dash.gpumap.legend.util": "Pale is idle, dark green is fully busy. A dashed border is a card no replica holds.",
|
||||
"dash.gpumap.free": "free",
|
||||
"dash.gpumap.busy": "%s%% busy",
|
||||
"dash.gpumap.memory": "%s of %s GB",
|
||||
"dash.gpumap.held_back": "held back by %s",
|
||||
"dash.gpumap.note": "A red outline is a card wearing out — XID errors, memory rows remapped or waiting for a reset, NVLink errors. The figure is the one coloured by; the rest of a card is under the pointer.",
|
||||
|
||||
"dash.eff.title": "What each model gets from its cards",
|
||||
"dash.eff.window": "last 15 minutes",
|
||||
"dash.col.model": "Model",
|
||||
"dash.eff.tps": "Output tokens/s",
|
||||
"dash.eff.per_gpu": "Per GPU",
|
||||
"dash.eff.busy": "Busy",
|
||||
"dash.eff.draw": "Draw",
|
||||
"dash.eff.per_wh": "Tokens per Wh",
|
||||
|
||||
"dash.idle.title": "Cards held and doing nothing",
|
||||
"dash.col.replica": "Replica",
|
||||
"dash.col.node": "Node",
|
||||
"dash.idle.busiest": "Busiest card",
|
||||
"dash.idle.last_request": "Last request",
|
||||
"dash.idle.ago": "%s ago",
|
||||
"dash.idle.unknown": "not known",
|
||||
"dash.clock.h": "h",
|
||||
"dash.clock.m": "m",
|
||||
"dash.clock.s": "s",
|
||||
"dash.clock.ms": "ms",
|
||||
|
||||
"dash.nodes": "Nodes",
|
||||
"dash.col.state": "State",
|
||||
"dash.nodes.ram": "RAM",
|
||||
"dash.nodes.disk": "Disk",
|
||||
"dash.nodes.engines": "Engines",
|
||||
"dash.nodes.build": "vLLM build",
|
||||
"dash.nodes.classes": "Classes",
|
||||
"dash.nodes.fabrics": "Fabrics",
|
||||
"dash.nodes.models": "Models",
|
||||
"dash.nodes.replicas": "Replicas",
|
||||
"dash.nodes.seen": "Seen",
|
||||
"dash.nodes.logs": "logs",
|
||||
"dash.nodes.cpus_of": "of %d",
|
||||
"dash.nodes.mem_of": "of %s",
|
||||
"dash.nodes.no_engines": "none yet",
|
||||
"dash.nodes.build_hint": "the vLLM build this node installs",
|
||||
"dash.no_agents": "No agents have registered.",
|
||||
|
||||
"dash.state.ready": "ready",
|
||||
"dash.state.cordoned": "cordoned",
|
||||
"dash.state.draining": "draining",
|
||||
"dash.state.unreachable": "unreachable",
|
||||
"dash.state.lost": "lost",
|
||||
"dash.state.unknown": "unknown",
|
||||
"dash.state.placing": "placing",
|
||||
"dash.state.pending": "pending",
|
||||
"dash.state.starting": "starting",
|
||||
"dash.state.loading": "loading",
|
||||
"dash.state.stopped": "stopped",
|
||||
"dash.state.failed": "failed",
|
||||
"dash.state.installing": "installing",
|
||||
"dash.state.unsupported": "unsupported",
|
||||
"dash.state.skipped": "skipped",
|
||||
|
||||
"dash.models": "Models",
|
||||
"dash.models.aliases": "Aliases",
|
||||
"dash.models.ready": "Ready",
|
||||
"dash.models.wanted": "Wanted",
|
||||
"dash.models.class": "Class",
|
||||
"dash.models.reserves": "Reserves",
|
||||
"dash.models.profiles": "Profiles",
|
||||
"dash.models.fallbacks": "Fallbacks",
|
||||
"dash.models.for": "What it is for",
|
||||
"dash.models.held_for": "gave room to %s",
|
||||
"dash.models.preempted": "replicas lost to a higher class",
|
||||
"dash.models.used": "%sG used",
|
||||
"dash.models.external_hint": "served outside the cluster; its prompts leave it",
|
||||
"dash.models.external": "external",
|
||||
"dash.models.empty": "The catalog is empty.",
|
||||
"dash.unplaced": "Why nothing was placed",
|
||||
|
||||
"dash.replicas": "Replicas",
|
||||
"dash.replicas.pool": "Pool",
|
||||
"dash.replicas.endpoint": "Endpoint",
|
||||
"dash.replicas.age": "Age",
|
||||
"dash.replicas.none": "No replicas.",
|
||||
"dash.holding": "Holding hardware nothing is asking for",
|
||||
"dash.holding.unused_for": "Unused for",
|
||||
|
||||
"dash.fabrics.fabric": "Fabric",
|
||||
"dash.fabrics.physically": "Is physically",
|
||||
"dash.fabrics.fully_free": "Fully free",
|
||||
"dash.fabrics.weights": "Weights",
|
||||
"dash.fabrics.weights_on": "%d model(s) on %d/%d nodes",
|
||||
"dash.fabrics.not_reported": "not reported",
|
||||
"dash.fabrics.see_map": "See each fabric's map →",
|
||||
"dash.fabrics.sms": "Subnet managers",
|
||||
"dash.fabrics.master": "Master",
|
||||
"dash.fabrics.standby": "Standby",
|
||||
"dash.fabrics.last_change": "Last change of master",
|
||||
"dash.fabrics.roce": "none: RoCE",
|
||||
"dash.fabrics.lid_priority": "LID %d, priority %d",
|
||||
"dash.fabrics.none": "none",
|
||||
"dash.fabrics.not_listed": "not listed",
|
||||
"dash.fabrics.in_a_day": "(%d in a day)",
|
||||
"dash.fabrics.none_in_a_day": "none in a day",
|
||||
"dash.fabrics.note": "A fabric's name is what the nodes were told; what it is physically is the subnet manager its InfiniBand ports see, or the network its RoCE ports are on. A replica spanning nodes is only ever formed on one of those. Fully free counts whole unused nodes, which is what decides whether such a replica can land.",
|
||||
|
||||
"dash.nvlink.title": "NVLink domains",
|
||||
"dash.nvlink.domains.one": "1 multi-node domain",
|
||||
"dash.nvlink.domains.other": "%d multi-node domains",
|
||||
"dash.nvlink.trays": "%d/%d trays usable",
|
||||
"dash.nvlink.problems.one": "1 problem",
|
||||
"dash.nvlink.problems.other": "%d problems",
|
||||
"dash.nvlink.see_racks": "see the racks →",
|
||||
|
||||
"dash.fleet.title": "What the cluster is doing",
|
||||
"dash.fleet.last": "last %s",
|
||||
"dash.no_history": "No history: %s",
|
||||
"dash.fleet.saturation": "Pool saturation, by model",
|
||||
"dash.fleet.saturation_note": "The worse of queue depth and cache pressure. 1 is the limit measured as healthy, not the point of failure.",
|
||||
"dash.fleet.ready": "Replicas ready, by model",
|
||||
"dash.fleet.ready_note": "A model deliberately stopped has no line at all, which is why an on-demand catalogue looks quiet here.",
|
||||
"dash.fleet.gpu_busy": "GPUs busy, by class",
|
||||
"dash.fleet.gpu_busy_note": "The average card of each class. Low here with replicas placed is capacity held and not asked for; each node's page has its cards one by one.",
|
||||
"dash.fleet.gpu_memory": "GPU memory in use, by class",
|
||||
"dash.fleet.gpu_memory_note": "Engines reserve most of a card's memory when they start, so this follows replicas placed more than requests served.",
|
||||
"dash.fleet.queue": "Engine queue depth, by replica",
|
||||
"dash.fleet.queue_note": "Requests accepted and not yet started. The first thing to move when a replica is falling behind.",
|
||||
"dash.fleet.kv": "KV cache occupancy, by replica",
|
||||
"dash.fleet.kv_note": "How full the attention cache is. Long contexts fill it with an empty queue, which is why saturation reads both.",
|
||||
|
||||
"dash.traffic": "Traffic",
|
||||
"dash.traffic.window": "— last 24 hours, hourly",
|
||||
"dash.traffic.requests": "Requests",
|
||||
"dash.traffic.tokens": "Tokens",
|
||||
"dash.traffic.latency": "Mean latency",
|
||||
"dash.traffic.none": "No traffic recorded yet.",
|
||||
"dash.unit.requests": "requests",
|
||||
"dash.unit.tokens": "tokens",
|
||||
"dash.unit.ms_mean": "ms mean",
|
||||
"dash.unit.requests_h": "requests/h",
|
||||
"dash.unit.tokens_h": "tokens/h",
|
||||
|
||||
"dash.people": "People",
|
||||
"dash.col.person": "Person",
|
||||
"dash.people.keys": "Keys",
|
||||
"dash.col.requests": "Requests",
|
||||
"dash.col.tokens": "Tokens",
|
||||
"dash.col.errors": "Errors",
|
||||
"dash.col.spend": "Spend",
|
||||
"dash.people.last_seen": "Last seen",
|
||||
"dash.people.operator_keys": "operator-issued keys",
|
||||
|
||||
"dash.running": "What is being run",
|
||||
"dash.running.note": "Counted by the model that actually answered, which differs from the one requested whenever a fallback served it.",
|
||||
|
||||
"dash.usage": "Usage",
|
||||
"dash.usage.window": "— last 24 hours",
|
||||
"dash.usage.through": "Through",
|
||||
"dash.usage.against": "Spends against",
|
||||
"dash.usage.prompt": "Prompt",
|
||||
"dash.usage.completion": "Completion",
|
||||
"dash.usage.last": "Last",
|
||||
"dash.usage.chat_page": "chat page",
|
||||
"dash.usage.personal": "personal",
|
||||
"dash.usage.note": "Spend is in units, whatever a unit was configured to mean. Usage is written in batches, so the last few seconds may be missing.",
|
||||
"dash.usage.none": "No requests recorded in the last 24 hours.",
|
||||
"dash.usage.auth_off": "Authentication is off, so there is no identity to account against.",
|
||||
|
||||
"dash.admin_listener": "Metrics, pprof and the management API are on the admin listener, which is bound to loopback and not reachable from here. pprof can dump the process heap, where API keys sit in plaintext, which is why that port is not exposed:"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"docs.title": "llmgw docs",
|
||||
"docs.description": "How to use and run llmgw: the client guide, installation, operation and the configuration reference.",
|
||||
"docs.this_gateway": "This gateway",
|
||||
"docs.about": "About llmgw",
|
||||
"docs.documents": "Documents",
|
||||
"docs.sub": "The manual this gateway's software carries — the same pages\n<code>llmgw docs</code> prints and the console's Manual shows.",
|
||||
"docs.from_code": "Using the gateway from code",
|
||||
"docs.from_code_text": "The official OpenAI and Anthropic SDKs work as they are: only the base URL\n and the key change. <a href=\"/docs/%s\">%s</a> shows Python,\n TypeScript, Go and curl for chat, streaming, tools, embeddings, the Responses\n API, batches, and what llmgw adds — stored conversations, tags, sessions and\n memory notes.",
|
||||
"docs.every": "Every document",
|
||||
"docs.general": "The documents describe llmgw in general, not this installation:\nthey name no address, model or key of it.",
|
||||
"docs.english_only": "",
|
||||
"docs.footer": "docs"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"fabric.title": "Fabrics",
|
||||
"fabric.sub.one": "1 physical fabric, as their adapters find them",
|
||||
"fabric.sub.other": "%d physical fabrics, as their adapters find them",
|
||||
"fabric.nav.cluster": "cluster",
|
||||
"fabric.empty": "No InfiniBand or RoCE port has reported yet. A node's adapters appear here with its first heartbeat, and its fabric's map within five minutes of that.",
|
||||
"fabric.pills": "Fabric",
|
||||
"fabric.undeclared": "undeclared",
|
||||
"fabric.master": "master",
|
||||
"fabric.lid": "LID %d",
|
||||
"fabric.sms.one": "1 subnet manager",
|
||||
"fabric.sms.other": "%d subnet managers",
|
||||
"fabric.mapped": "mapped %s through %s in %s ms",
|
||||
"fabric.level.fail": "fail",
|
||||
"fabric.level.warn": "warn",
|
||||
"fabric.level.info": "info",
|
||||
"fabric.gbps": "%d Gb/s",
|
||||
"fabric.mode.speed": "Speed",
|
||||
"fabric.mode.rails": "Rails",
|
||||
"fabric.traffic": "Traffic",
|
||||
"fabric.only_problems": "Only problems",
|
||||
"fabric.find": "Find a node",
|
||||
"fabric.reset_hint": "Or double-click the map",
|
||||
"fabric.reset": "Reset view",
|
||||
"fabric.noscript": "The map is drawn in the browser; the problems above are the same without it.",
|
||||
"fabric.legend.slower": "slower",
|
||||
"fabric.legend.below": "below its kind",
|
||||
"fabric.legend.ours": "this cluster's adapter",
|
||||
"fabric.legend.other": "another host",
|
||||
"fabric.legend.down": "port down",
|
||||
"fabric.legend.how": "scroll to zoom · drag to move · click for detail",
|
||||
|
||||
"fabric.js.map_failed": "The map could not be made: %s",
|
||||
"fabric.js.not_mapped": "Not mapped yet: the first scan runs within five minutes of a node on this fabric connecting.",
|
||||
"fabric.js.tier.leaf": "leaf",
|
||||
"fabric.js.tier.spine": "spine",
|
||||
"fabric.js.tier.core": "core",
|
||||
"fabric.js.tier.supercore": "super-core",
|
||||
"fabric.js.tier.n": "tier %s",
|
||||
"fabric.js.adapters_row": "adapters",
|
||||
"fabric.js.ports_used": "%s/%s ports",
|
||||
"fabric.js.hosts": "%s hosts",
|
||||
"fabric.js.cables_n": "%s cables, ",
|
||||
"fabric.js.below_rate": "trained below the %s Gb/s the rest of its kind run at",
|
||||
"fabric.js.adapters_n": "%s adapters",
|
||||
"fabric.js.degraded_n": "%s degraded cable(s)",
|
||||
"fabric.js.rail": "rail %s",
|
||||
"fabric.js.not_ours": "not one of this cluster's nodes",
|
||||
"fabric.js.dt.tier": "tier",
|
||||
"fabric.js.dt.ports": "ports",
|
||||
"fabric.js.dt.adapters": "adapters",
|
||||
"fabric.js.dt.adapter": "adapter",
|
||||
"fabric.js.dt.rail": "rail",
|
||||
"fabric.js.dt.leaf": "leaf",
|
||||
"fabric.js.dt.state": "state",
|
||||
"fabric.js.dt.rate": "rate",
|
||||
"fabric.js.dt.sending": "sending",
|
||||
"fabric.js.dt.receiving": "receiving",
|
||||
"fabric.js.cabled": "%s of %s cabled",
|
||||
"fabric.js.degraded_cables": "Degraded cables",
|
||||
"fabric.js.all_good": "Every cable at its expected rate.",
|
||||
"fabric.js.port_to": "port %s → %s: %s",
|
||||
"fabric.js.close": "close",
|
||||
"fabric.js.open_node": "open %s →",
|
||||
"fabric.js.adapter": "adapter",
|
||||
"fabric.js.stat.switches": "switches",
|
||||
"fabric.js.stat.adapters": "adapters",
|
||||
"fabric.js.stat.cables": "cables",
|
||||
"fabric.js.stat.degraded": "degraded",
|
||||
"fabric.js.stat.rails": "rails",
|
||||
"fabric.js.stat.aligned": "aligned",
|
||||
"fabric.js.stat.crossed": "crossed",
|
||||
"fabric.js.stat.sent": "sent now",
|
||||
|
||||
"nvlink.title": "NVLink domains",
|
||||
"nvlink.sub.one": "1 multi-node domain — racks whose GPUs reach each other over NVLink across nodes",
|
||||
"nvlink.sub.other": "%d multi-node domains — racks whose GPUs reach each other over NVLink across nodes",
|
||||
"nvlink.empty": "No GPU reports a multi-node NVLink domain. An eight-GPU machine's NVSwitch ends at its chassis and is shown on its node's page; a domain appears here once the fabric manager has joined the GPUs of an NVL72 rack, or an NVLink Switch system, to one.",
|
||||
"nvlink.clique": "clique %v",
|
||||
"nvlink.trays_usable": "trays usable",
|
||||
"nvlink.gpus_joined": "GPUs joined",
|
||||
"nvlink.no_fabric": "no fabric",
|
||||
"nvlink.imex": "nvidia-imex %s, %d channel(s)",
|
||||
"nvlink.note": "A replica that spans nodes is placed inside one of these first: tensor parallelism between its nodes then runs over NVLink rather than InfiniBand. Only trays with every GPU joined and IMEX up are used — a tray short of either would leave the replica a card, or its memory, short."
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"guard.title": "Guardrails",
|
||||
"guard.sub": "What the policies did, where, and by which rule — names and counts, never what was written.",
|
||||
"guard.policies": "Policies",
|
||||
"guard.defined_in": "Defined in <code>server.yaml</code>:",
|
||||
"guard.applied": "Applied to every request:",
|
||||
"guard.col.scope": "Scope",
|
||||
"guard.col.who": "Who",
|
||||
"guard.col.policy": "Policy",
|
||||
"guard.scope.org": "org",
|
||||
"guard.scope.team": "team",
|
||||
"guard.scope.project": "project",
|
||||
"guard.scope.user": "user",
|
||||
"guard.scope.key": "key",
|
||||
"guard.attached_nowhere": "Attached nowhere else. <code>llmgw ctl guard attach project:ORG/TEAM/PROJECT POLICY</code> attaches one.",
|
||||
"guard.no_policies": "No policies: add a <code>guard:</code> section to <code>server.yaml</code> (docs/guardrails.md).",
|
||||
|
||||
"guard.what_they_did": "What they did",
|
||||
"guard.over_last": "Over the last",
|
||||
"guard.col.where": "Where",
|
||||
"guard.col.action": "Action",
|
||||
"guard.col.category": "Category",
|
||||
"guard.col.requests": "Requests",
|
||||
"guard.personal": "personal",
|
||||
"guard.action.flagged": "flagged",
|
||||
"guard.action.redacted": "redacted",
|
||||
"guard.action.blocked": "blocked",
|
||||
"guard.nothing": "Nothing flagged, redacted or blocked.",
|
||||
|
||||
"guard.recent": "Recent",
|
||||
"guard.col.when": "When (UTC)",
|
||||
"guard.col.request": "Request",
|
||||
"guard.col.model": "Model",
|
||||
"guard.col.status": "Status",
|
||||
"guard.request_hint": "<code>llmgw ctl request ID</code> says what else a request went through."
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"landing.description": "One OpenAI-compatible endpoint in front of self-hosted models.",
|
||||
"landing.open_warning": "This gateway has no API keys configured, so it serves anyone\nwho can reach it. That is a deployment choice, and only safe on a trusted network.",
|
||||
"landing.sub": "One OpenAI-compatible endpoint in front of your own GPUs.",
|
||||
"landing.lede": "Point any OpenAI client at this address. The API is the one you\n already have a client for — only the base URL changes.",
|
||||
"landing.create_account": "Create an account",
|
||||
"landing.sign_in": "Sign in",
|
||||
"landing.schematic": "Clients reach one endpoint, which places work on GPU nodes across separate fabrics.",
|
||||
"landing.schematic.client": "your client",
|
||||
"landing.schematic.editor": "editor agent",
|
||||
"landing.schematic.script": "a script",
|
||||
"landing.schematic.address": "one address",
|
||||
"landing.schematic.fabric": "fabric",
|
||||
"landing.schematic_caption": "Clients see one address. Work is placed on GPUs that\n can actually run it — and a model split across machines never straddles two fabrics.",
|
||||
"landing.base_url": "Base URL",
|
||||
"landing.three_steps": "Three steps",
|
||||
"landing.step1": "Get a key",
|
||||
"landing.step1_signup": "<a href=\"/signup\">Create an account</a>. Someone approves it, and then you issue\n your own keys from your <a href=\"/profile\">profile</a> — as many as you need,\n revoking them yourself when a laptop is retired or a script is done.",
|
||||
"landing.step1_contact": "Ask %s\n for one.",
|
||||
"landing.step1_contact_signin": " If you already have an account,\n <a href=\"/login\">sign in</a>.",
|
||||
"landing.step1_nobody": "Keys are issued by whoever operates this gateway. If you do not know who that\n is, the address you reached this page at is the place to start asking.",
|
||||
"landing.step1_shown_once": "A key is shown once and never again — only its hash is kept here —\n so put it in a password manager straight away.",
|
||||
"landing.step2": "Point your client at it",
|
||||
"landing.step2_text": "Set the base URL above and your key. Every OpenAI SDK, and every tool built on\n one, takes both as configuration.",
|
||||
"landing.step3": "Pick a model",
|
||||
"landing.step3_keyed": "<code>GET /v1/models</code> lists what is available to your key. It needs the\n key, which is why this page does not list them: this page is served to anyone who\n can reach the address.",
|
||||
"landing.step3_open": "<code>GET /v1/models</code> lists what is available.",
|
||||
"landing.step3_placeholder": "Sending the placeholder <code><model-name></code> unchanged is\n answered with the list of models you may use, rather than with an error.",
|
||||
"landing.connect": "Connect your client",
|
||||
"landing.anything_works": "Anything that speaks the chat-completions API works, whether or not it\nis listed here — there is nothing llmgw-specific to install.",
|
||||
"landing.once_you_have_key": "Once you have a key",
|
||||
"landing.models_endpoint": "what your key may use",
|
||||
"landing.me_endpoint": "its limits, and what it has spent",
|
||||
"landing.client_guide": "The <a href=\"/docs/clients\">client guide</a> has more: streaming, tools, embeddings and the Anthropic SDK."
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"mirror.title": "Mirror",
|
||||
"mirror.heading": "Model mirror",
|
||||
"mirror.did_not_answer": "did not answer",
|
||||
"mirror.tabs_aria": "Mirror",
|
||||
"mirror.tab.held": "Held",
|
||||
"mirror.tab.unreachable": "unreachable",
|
||||
"mirror.tab.downloading": "%s · %s downloading",
|
||||
"mirror.tab.failed": "%s failed",
|
||||
"mirror.tab.explore": "Explore Hugging Face",
|
||||
"mirror.tab.fetch": "Fetch by URL",
|
||||
"mirror.unreachable": "Nothing was read from the mirror, so this page is not a list of what it holds — it is not currently reachable at <code>%s</code>. Check that <code>llmgw-mirror</code> is running there and that its management port is reachable from this machine.",
|
||||
|
||||
"mirror.col.model": "Model",
|
||||
"mirror.col.files": "Files",
|
||||
"mirror.col.size": "Size",
|
||||
"mirror.col.runs": "Runs",
|
||||
"mirror.group.models.one": "%d model · %s",
|
||||
"mirror.group.models.other": "%d models · %s",
|
||||
"mirror.state.fetching": "downloading",
|
||||
"mirror.state.failed": "failed",
|
||||
"mirror.group.in_use": "in use",
|
||||
"mirror.adapter_title": "a LoRA adapter for %s models: attach it on a model's card",
|
||||
"mirror.adapter": "LoRA adapter",
|
||||
"mirror.file_sha": "%s · sha256 %s",
|
||||
"mirror.files.one": "%d file",
|
||||
"mirror.files.other": "%d files",
|
||||
"mirror.percent_of": "%d%% of %s",
|
||||
"mirror.so_far": "%s so far",
|
||||
"mirror.use.as": "as %s",
|
||||
"mirror.use.yaml_title": "set in server.yaml, which is read again on every start",
|
||||
"mirror.use.yaml": "%s · yaml",
|
||||
"mirror.mode.always": "always on",
|
||||
"mirror.mode.ondemand": "on demand",
|
||||
"mirror.mode.stopped": "stopped",
|
||||
"mirror.use.mode_aria": "How %s runs",
|
||||
"mirror.use.set": "<button type=\"submit\" class=\"link\">set</button>",
|
||||
"mirror.use.runs_title": "replicas ready / wanted",
|
||||
"mirror.use.runs_title_starting": "replicas ready / wanted, + starting",
|
||||
"mirror.use.parked": "parked",
|
||||
"mirror.use.none_running": "none running",
|
||||
"mirror.use.no_room": "no room",
|
||||
"mirror.not_served": "not served",
|
||||
"mirror.retry": "retry",
|
||||
"mirror.copy_address": "copy address",
|
||||
"mirror.close": "close",
|
||||
"mirror.serve": "serve",
|
||||
"mirror.remove_confirm": "Remove %s/%s and every file in it?",
|
||||
"mirror.remove_confirm_in_use": "Remove %s/%s and every file in it? It is in use by the catalog.",
|
||||
"mirror.remove": "remove",
|
||||
"mirror.copy_note": "“copy address” gives what a catalog entry's <code>source</code> should say — the file, or for a version of several files the directory, with its trailing slash. It is rendered rather than assembled by hand because the mirror and the catalog have to spell a file the same way, which is also how this page knows which models use it.",
|
||||
"mirror.address_prompt": "Address",
|
||||
"mirror.empty": "The mirror is reachable and holds nothing yet. <a href=\"/admin/mirror?tab=fetch\">fetch one by URL</a>.",
|
||||
"mirror.empty_explore": "The mirror is reachable and holds nothing yet. <a href=\"/admin/mirror?tab=explore\">Explore Hugging Face</a> for a model, or <a href=\"/admin/mirror?tab=fetch\">fetch one by URL</a>.",
|
||||
|
||||
"explore.views_aria": "Which models",
|
||||
"explore.view.trending": "Trending",
|
||||
"explore.view.new": "New this month",
|
||||
"explore.view.downloads": "Most downloaded",
|
||||
"explore.view.adapters": "LoRA adapters",
|
||||
"explore.adapters_for": "LoRA adapters made for <code>%s</code> ·",
|
||||
"explore.all_adapters": "all adapters",
|
||||
"explore.adapters_any": "LoRA adapters for any model. From a held model's card, <em>find adapters</em> lists those made for it.",
|
||||
"explore.adapters_note": "An adapter runs on the model it was made for, and only as a GGUF adapter file; open one to see which of its files are.",
|
||||
"explore.task_aria": "Task",
|
||||
"explore.task.chat": "Chat",
|
||||
"explore.task.embed": "Embeddings",
|
||||
"explore.task.any": "Any task",
|
||||
"explore.size_aria": "Size",
|
||||
"explore.size.any": "Any size",
|
||||
"explore.search_placeholder": "Search Hugging Face — qwen coder, gemma, bge…",
|
||||
"explore.search_aria": "Search Hugging Face",
|
||||
"explore.only_here": "only what these nodes can run",
|
||||
"explore.search": "Search",
|
||||
"explore.registry_err": "The registry: %s",
|
||||
"explore.col.repo": "Repository",
|
||||
"explore.col.size": "Size",
|
||||
"explore.col.arch": "Architecture",
|
||||
"explore.col.context": "Context",
|
||||
"explore.col.downloads": "Downloads",
|
||||
"explore.col.likes": "Likes",
|
||||
"explore.col.updated": "Updated",
|
||||
"explore.new": "new",
|
||||
"explore.gated": "gated",
|
||||
"explore.from": "from %s",
|
||||
"explore.age.now": "just now",
|
||||
"explore.age.h": "%dh",
|
||||
"explore.age.d": "%dd",
|
||||
"explore.age.w": "%dw",
|
||||
"explore.age.mo": "%dmo",
|
||||
"explore.js.loading": "Loading…",
|
||||
"explore.js.load_failed": "Could not load it:",
|
||||
"explore.js.starting": "Starting…",
|
||||
"explore.js.refused": "refused",
|
||||
"explore.js.follow": "follow it under Held",
|
||||
"explore.js.started": "Started",
|
||||
"explore.js.unreachable": "Could not reach the gateway:",
|
||||
"explore.hidden.one": "%d more is hidden: their architecture is one the nodes' llama.cpp cannot load.",
|
||||
"explore.hidden.other": "%d more are hidden: their architecture is one the nodes' llama.cpp cannot load.",
|
||||
"explore.untick": "Untick “only what these nodes can run” to see them.",
|
||||
"explore.gated_note": "A gated repository needs its terms accepted with a Hugging Face account, and this browses anonymously — the files will list, and fetching one will fail at the mirror. Download it by hand and put it in the mirror directory, or give the mirror a token.",
|
||||
"explore.empty.query_hidden.one": "Nothing matched “%[2]s” that these nodes can run — %[1]d result was hidden for an architecture llama.cpp cannot load.",
|
||||
"explore.empty.query_hidden.other": "Nothing matched “%[2]s” that these nodes can run — %[1]d results were hidden for an architecture llama.cpp cannot load.",
|
||||
"explore.empty.query": "Nothing matched “%s”.",
|
||||
"explore.empty.hidden.one": "Nothing to list that these nodes can run — %d result was hidden for an architecture llama.cpp cannot load.",
|
||||
"explore.empty.hidden.other": "Nothing to list that these nodes can run — %d results were hidden for an architecture llama.cpp cannot load.",
|
||||
"explore.empty.nothing": "Nothing to list.",
|
||||
|
||||
"mirror.fetch.intro": "By hand, for anything the search does not reach.",
|
||||
"mirror.fetch.source": "Source URL",
|
||||
"mirror.fetch.source_example": "https://huggingface.co/…/resolve/main/model.gguf",
|
||||
"mirror.fetch.model": "Model",
|
||||
"mirror.fetch.model_example": "qwen2.5-coder-7b",
|
||||
"mirror.fetch.version": "Version",
|
||||
"mirror.fetch.file": "File",
|
||||
"mirror.fetch.file_hint": "taken from the URL",
|
||||
"mirror.fetch.force": "Fetch anyway",
|
||||
"mirror.fetch.force_hint": "even if the nodes' llama.cpp cannot load it",
|
||||
"mirror.fetch.start": "Start download",
|
||||
"mirror.fetch.note": "The mirror answers as soon as the download is under way, not when it finishes — a model is tens of gigabytes and nothing here holds a connection open for that. The Held tab shows it arriving. A version is never overwritten: fetching into one that exists is refused, so a new build is a new version and the old weights stay where they are.",
|
||||
|
||||
"card.back": "← back to the list",
|
||||
"card.at": "at %s",
|
||||
"card.at_code": "at <code>%s</code>",
|
||||
"card.from": "from",
|
||||
"card.license": "license %s",
|
||||
"card.on_registry": "on Hugging Face ↗",
|
||||
"card.model_card": "Model card",
|
||||
"card.col.what": "What",
|
||||
"card.col.size": "Size",
|
||||
"card.col.checksum": "Published checksum",
|
||||
"card.shards": "%d shards, fetched together",
|
||||
"card.companions": "with %d files the engine reads beside them",
|
||||
"card.adapter_title": "a LoRA adapter: it runs on a %s model it was made for, not on its own",
|
||||
"card.adapter": "LoRA adapter · %s",
|
||||
"card.model_arch": "model · %s",
|
||||
"card.model": "model",
|
||||
"card.cannot_serve": "cannot be served",
|
||||
"card.imatrix": "it is an importance matrix for quantising, not a model",
|
||||
"card.fetch": "Fetch",
|
||||
"card.weights_note": "Only the weights are listed; a repository is mostly a README and a tokenizer. A safetensors set is fetched with the files vLLM reads beside it — config.json, the tokenizer, the shard index — and never with code. Shards of one file are one row and one button — a fifteen-shard model is one decision, not fifteen. Two separate files are two rows because they are usually alternatives: one quantisation or the other, not both.",
|
||||
"card.pinned_note": "The source URLs are pinned to <code>%s</code> rather than to a branch, so what arrives is what is listed here even if the branch moves in between — and a single file's checksum above is what the registry publishes, to compare with what the mirror computes once it has it.",
|
||||
"card.no_weights": "No weight files in %s.",
|
||||
|
||||
"mirror.detail.not_in_catalog": "Not in the catalog: nothing runs this version.",
|
||||
"mirror.detail.use_serve": "Use <strong>serve</strong> on its row to put it into service.",
|
||||
"mirror.detail.ready": "%d of %d ready",
|
||||
"mirror.detail.in_all": "%d in all",
|
||||
"mirror.detail.does_not_fit": "does not fit",
|
||||
"mirror.replica.col.replica": "Replica",
|
||||
"mirror.replica.col.node": "Node",
|
||||
"mirror.replica.col.state": "State",
|
||||
"mirror.replica.col.age": "Age",
|
||||
"mirror.replica.col.memory": "Memory",
|
||||
"mirror.replica.placing": "placing",
|
||||
"mirror.replica.starting": "starting",
|
||||
"mirror.replica.ready": "ready",
|
||||
"mirror.replica.draining": "draining",
|
||||
"mirror.replica.stopped": "stopped",
|
||||
"mirror.replica.failed": "failed",
|
||||
"mirror.replica.unknown": "unknown",
|
||||
"mirror.replica.restart": "restart %s",
|
||||
"mirror.restart.queued": "queued",
|
||||
"mirror.restart.replacing": "replacing",
|
||||
"mirror.restart.in_place": "in place",
|
||||
"mirror.replica.cores": "%s cores",
|
||||
"mirror.detail.none_running": "No replica runs now.",
|
||||
"mirror.detail.none_running_ondemand": "No replica runs now: it starts when somebody asks for it.",
|
||||
"mirror.room.at_least": "Room for at least %d more replicas.",
|
||||
"mirror.room.more_on": "Room for %d more now, on %s.",
|
||||
"mirror.room.more": "Room for %d more now.",
|
||||
"mirror.room.none": "No free room for another replica now.",
|
||||
"mirror.room.and_preempting": "And %d more by stopping replicas its class outranks: %s.",
|
||||
"mirror.room.preempting": "%d by stopping replicas its class outranks: %s.",
|
||||
"mirror.room.why_not_more": "Why not more",
|
||||
"mirror.room.why_not_another": "Why not another",
|
||||
"mirror.adapters.heading": "LoRA adapters",
|
||||
"mirror.adapters.find": "find adapters on Hugging Face →",
|
||||
"mirror.adapters.asked_as": "Asked for as",
|
||||
"mirror.adapters.file": "File",
|
||||
"mirror.adapters.scale": "Scale",
|
||||
"mirror.adapters.none": "None. A request for an adapter's name is served by this model's replicas with it applied.",
|
||||
"mirror.adapters.adapter_file": "Adapter file",
|
||||
"mirror.adapters.attach": "Attach",
|
||||
"mirror.adapters.no_choice": "No held adapter file for this model's architecture. Fetch one from Hugging Face first.",
|
||||
"mirror.detail.declared": "Set in server.yaml, which is read again on every start: change its replicas there.",
|
||||
"mirror.form.min": "Always running",
|
||||
"mirror.form.max": "Up to, under load",
|
||||
"mirror.form.class": "Class",
|
||||
"mirror.class.default": "default (%s)",
|
||||
"mirror.class.critical": "critical",
|
||||
"mirror.class.standard": "standard",
|
||||
"mirror.class.opportunistic": "opportunistic",
|
||||
"mirror.form.owner": "Owner",
|
||||
"mirror.form.shared": "shared",
|
||||
"mirror.form.memory": "Memory GB",
|
||||
"mirror.form.per_replica": "per replica",
|
||||
"mirror.form.cores": "Cores",
|
||||
"mirror.form.cores_hint": "per replica, its threads",
|
||||
"mirror.form.most_now": "At most %d can always run now.",
|
||||
"mirror.form.most_now_preempting": "At most %d can always run now, counting what its class may stop to make room.",
|
||||
"mirror.form.note": "0 always running with a higher limit loads it when asked for; 0 and 0 stops it. A replica is added under load when requests wait, and taken away after ten quiet minutes. A higher class may stop replicas of lower ones to make room; memory and cores apply to replicas started after the change.",
|
||||
|
||||
"mirror.err.source_required": "a source URL is required",
|
||||
"mirror.err.not_fetched": "not fetched: %s. Tick \"fetch anyway\" to take it regardless",
|
||||
"mirror.err.partly_started": "%d of %d started, then %s: %s",
|
||||
"mirror.msg.started": "started downloading %s/%s (%s)",
|
||||
"mirror.err.choose_mode": "choose a model and how it should run",
|
||||
"mirror.msg.ondemand": "%s now loads when asked for and stops after 15 minutes unused",
|
||||
"mirror.msg.stopped": "%s is stopped; its replicas are being taken down",
|
||||
"mirror.msg.always": "%s now runs all the time",
|
||||
"mirror.msg.removed": "removed %s/%s (%s)",
|
||||
"mirror.err.which_model": "which model?",
|
||||
"mirror.err.replicas_whole": "replicas are whole numbers",
|
||||
"mirror.err.min_over_max": "always running (%d) cannot be more than the most under load (%d)",
|
||||
"mirror.err.at_most": "at most %d replicas",
|
||||
"mirror.err.declared": "%s is set in server.yaml; change it there",
|
||||
"mirror.err.no_model": "no model %q",
|
||||
"mirror.err.nothing_fits": "nothing more fits",
|
||||
"mirror.err.no_room": "%s can always run at most %d now (%d running, room for %d more): %s",
|
||||
"mirror.msg.room_for": "there is room for %d now, so it grows past that only if room is made",
|
||||
"mirror.msg.will_stop": "to make room, replicas of %s will be stopped",
|
||||
"mirror.msg.loads_up_to": "%s loads when asked for, up to %d under load",
|
||||
"mirror.msg.runs_n": "%s runs %s",
|
||||
"mirror.msg.runs_up_to": "%s runs %d, and up to %d under load",
|
||||
"mirror.err.not_from_mirror": "no model %q runs from the mirror",
|
||||
"mirror.err.memory_whole": "memory and cores are whole numbers",
|
||||
"mirror.msg.reserves": "a replica reserves %d GB and %d cores (running ones keep theirs until restarted)",
|
||||
"mirror.msg.is_class": "is %s",
|
||||
"mirror.msg.is_shared": "is shared",
|
||||
"mirror.msg.counts_against": "counts against %s",
|
||||
"mirror.err.partly_set": "%s %s, but not the replicas: %s",
|
||||
"mirror.msg.nothing_changed": "%s: nothing changed",
|
||||
"mirror.msg.changed": "%s %s",
|
||||
"mirror.err.no_adapter": "%s has no adapter %q",
|
||||
"mirror.msg.adapter_removed": "%[1]s: adapter %[2]s removed; replicas drop it when restarted (llmgw ctl restart --model %[1]s)",
|
||||
"mirror.err.scale": "the scale is a positive number",
|
||||
"mirror.err.adapter_name_file": "an adapter needs a name and a file",
|
||||
"mirror.err.adapter_unreadable": "the adapter file could not be read: %s",
|
||||
"mirror.err.not_adapter": "that file is a model, not a LoRA adapter",
|
||||
"mirror.err.wrong_arch": "that adapter is for %s models and %s is %s",
|
||||
"mirror.msg.adapter_added": "%[1]s: adapter %[2]s added; ask for it by that name once the replicas are restarted (llmgw ctl restart --model %[1]s)",
|
||||
"mirror.err.adapter_op": "add or remove an adapter"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"nav.aria": "Main",
|
||||
"nav.org": "Organization",
|
||||
"nav.usage": "Usage",
|
||||
"nav.quotas": "Quotas",
|
||||
"nav.cluster": "Cluster",
|
||||
"nav.people": "People",
|
||||
"nav.mirror": "Mirror",
|
||||
"nav.guard": "Guardrails",
|
||||
"nav.chat": "Chat",
|
||||
"nav.account": "Account",
|
||||
"nav.sign_out": "Sign out",
|
||||
"nav.docs": "Docs"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"node.last_seen": "last seen %s ago",
|
||||
"node.never_seen": "never seen",
|
||||
"node.nav.cluster": "cluster",
|
||||
"node.cores": "%d cores",
|
||||
"node.ram_in_use_of": "RAM in use · of %s",
|
||||
"node.load": "load · %s 5m · %s 15m",
|
||||
"node.inlet": "inlet air",
|
||||
"node.not_measured": "CPU and RAM not measured lately",
|
||||
"node.disk_free": "disk free · of %dG",
|
||||
"node.gpus_free": "GPUs free",
|
||||
|
||||
"node.gpus": "GPUs",
|
||||
"node.gpu.busy": "Busy",
|
||||
"node.gpu.memory_used": "Memory used",
|
||||
"node.gpu.temp": "Temperature",
|
||||
"node.gpu.memory_temp": "Memory temp.",
|
||||
"node.gpu.power": "Power",
|
||||
"node.gpu.clock": "Clock",
|
||||
"node.gpu.held_back": "Held back by, and wear",
|
||||
"node.gpu.memory_util": "memory %s%%",
|
||||
"node.of_gb": "of %s GB",
|
||||
"node.of": "of %s",
|
||||
"node.gpu.nothing": "nothing",
|
||||
"node.gpu.ecc": "%s uncorrected ECC errors",
|
||||
"node.gpu.pcie": "%s PCIe retries",
|
||||
|
||||
"node.fabric": "Fabric",
|
||||
"node.fabric.on": "On %s",
|
||||
"node.fabric.map": "the map →",
|
||||
"node.fabric.none": "On no fabric: no port a declared fabric names can carry traffic, so no multi-node replica is placed here.",
|
||||
"node.ib.adapter": "Adapter",
|
||||
"node.ib.rate": "Rate",
|
||||
"node.ib.physically": "Physically on",
|
||||
"node.ib.errors": "Errors since boot",
|
||||
"node.ib.sent": "Sent",
|
||||
"node.ib.received": "Received",
|
||||
"node.unknown": "unknown",
|
||||
"node.none": "none",
|
||||
"node.ib.note": "Re-read on every heartbeat. A port that goes down takes its fabric off this node within seconds; one that comes back returns it. Errors are cumulative since the adapter came up: a number that grows is the one to look at.",
|
||||
|
||||
"node.nvl.title": "GPUs and NVLink",
|
||||
"node.nvl.domain": "In NVLink domain <code>%s</code>: tensor parallelism can span this node and the rest of its domain.",
|
||||
"node.nvl.ends_here": "NVLink ends at this machine.",
|
||||
"node.imex.active": "active",
|
||||
"node.imex.channels.one": "1 channel",
|
||||
"node.imex.channels.other": "%d channels",
|
||||
"node.imex.not_installed": "not installed",
|
||||
"node.nvl.matrix": "NVLink between GPUs",
|
||||
"node.nvl.over_nvlink": "GPU %d ↔ GPU %d over NVLink",
|
||||
"node.nvl.over_pcie": "GPU %d ↔ GPU %d over PCIe",
|
||||
"node.gpu.name": "Name",
|
||||
"node.gpu.compute": "Compute",
|
||||
"node.gpu.numa": "NUMA",
|
||||
"node.gpu.nearest": "Nearest adapter",
|
||||
"node.gpu.multinode": "Multi-node NVLink",
|
||||
"node.gpu.joined": "joined",
|
||||
"node.gpu.bandwidth": "%s bandwidth",
|
||||
"node.nvl.note": "NV is a pair of cards joined by NVLink; a tensor-parallel group kept to NV pairs runs its all-reduces at NVLink speed, and placement picks it that way. The nearest adapter is where a card's share of traffic between nodes should go: PIX and PXB are under the same PCIe switch, SYS is across the processors.",
|
||||
|
||||
"node.runs": "What runs here",
|
||||
"node.memory": "Memory",
|
||||
"node.cores_f": "%s cores",
|
||||
"node.no_engines": "No engines are placed on this node.",
|
||||
"node.engines": "Engines: %s",
|
||||
"node.engines_none": "Engines: none installed",
|
||||
"node.model_files": "%d model file(s) on disk",
|
||||
|
||||
"node.last": "The last %s",
|
||||
"node.chart.cpu_note": "All cores together, from the node's heartbeat.",
|
||||
"node.chart.ram": "RAM in use",
|
||||
"node.chart.ram_note": "Total less what the kernel could hand to a new process.",
|
||||
"node.chart.cores": "Cores busy, by model",
|
||||
"node.chart.cores_note": "Each engine's process group, the way top counts it.",
|
||||
"node.chart.engine_memory": "Engine memory, by model",
|
||||
"node.chart.engine_memory_note": "Resident memory, including the weights mapped from disk. The kernel can take those pages back, so this can exceed RAM in use.",
|
||||
"node.chart.gpu_busy": "GPU busy, by card",
|
||||
"node.chart.gpu_busy_note": "Of each sample period, the share a kernel was running. A card held by a replica and idle here is one nobody is asking.",
|
||||
"node.chart.gpu_memory": "GPU memory used, by card",
|
||||
"node.chart.gpu_memory_note": "Framebuffer in use: the weights and the KV cache an engine reserved, and anything not llmgw's.",
|
||||
"node.chart.gpu_temp": "GPU temperature, °C",
|
||||
"node.chart.gpu_temp_note": "Throttling usually starts in the high 80s; see what holds a card back in the table above.",
|
||||
"node.chart.gpu_power": "GPU power, W",
|
||||
"node.chart.gpu_power_note": "Draw against the card's enforced limit; at the limit is sw_power_cap.",
|
||||
"node.chart.load": "Load average",
|
||||
"node.chart.load_note": "Above the core count means work is waiting for a CPU.",
|
||||
"node.chart.queue_note": "Requests accepted and not yet started, on the replicas here now.",
|
||||
"node.no_store": "No metrics store is configured, so there is no history here; <code>monitoring.prometheus</code> in the server configuration adds it.",
|
||||
|
||||
"logs.title": "%s logs",
|
||||
"logs.agent": "the agent's log",
|
||||
"logs.engine": "output of engine <code>%s</code>",
|
||||
"logs.last_lines": "last %d lines",
|
||||
"logs.lines": "lines",
|
||||
"logs.stop": "stop following",
|
||||
"logs.follow": "follow",
|
||||
"logs.follow_every": "(refresh every 5s)",
|
||||
"logs.empty": "Nothing logged yet.",
|
||||
"logs.cannot_read": "this control plane cannot read node logs",
|
||||
|
||||
"nodeengine.notice.auto": "%s installs %s in the build its hardware runs; the node is told now, and installs it in the background",
|
||||
"nodeengine.notice.none": "%s installs %s no longer; the node is told now, and installs it in the background",
|
||||
"nodeengine.notice.build": "%s installs %s built for %s; the node is told now, and installs it in the background"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"org.sub.one": "1 person · use, members and limits across the organization",
|
||||
"org.sub.other": "%d people · use, members and limits across the organization",
|
||||
"org.filter": "Organization",
|
||||
|
||||
"org.use": "Use",
|
||||
"org.requests_note": "Requests made in this organization's projects.",
|
||||
"org.tokens_note": "Prompt and completion together.",
|
||||
"org.spend_note": "In whole units, the ones a budget is written in.",
|
||||
"org.col.person": "Person",
|
||||
"org.col.project": "Project",
|
||||
"org.col.team": "Team",
|
||||
"org.use_empty": "Nothing used in this window.",
|
||||
"org.use_scope": "Only what was spent in this organization's projects. What its people do elsewhere — in another organization, or on their own — is not shown.",
|
||||
|
||||
"org.limits.title": "Teams, projects and limits",
|
||||
"org.col.spent_30d": "Spent · 30 days",
|
||||
"org.limits.note": "Limits are set by the cluster's administrators.",
|
||||
|
||||
"org.members.title": "Members",
|
||||
"org.col.member_of": "Member of",
|
||||
"org.col.state": "State",
|
||||
"org.role.manager": "manager",
|
||||
"org.state.pending": "pending",
|
||||
"org.state.active": "active",
|
||||
"org.state.suspended": "suspended",
|
||||
"org.members.empty": "Nobody is a member yet.",
|
||||
"org.whole": "the whole organization",
|
||||
|
||||
"org.none.title": "Organizations",
|
||||
"org.none.sub": "There are none on this cluster",
|
||||
"org.none.about": "Everyone uses the gateway on their own, under their personal quota. An organization, once there is one, gets a page here for its managers: its use by team, project and person, its limits, and its members.",
|
||||
"org.none.how": "<a href=\"/admin/users\">Create one on the people page</a>, then make someone its manager with the star on their membership.",
|
||||
|
||||
"org.err.read_tree": "could not read the organization"
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
{
|
||||
"people.title": "People",
|
||||
"people.sub": "Organizations, teams, projects and the people in them: what each may use, and what it used",
|
||||
"people.tab.people": "Organizations & people",
|
||||
"people.waiting": "%d waiting",
|
||||
"people.tab.limits": "Limits",
|
||||
"people.tab.usage": "Usage",
|
||||
"people.search.placeholder": "Find a person by email, name or project",
|
||||
"people.search.label": "Find a person",
|
||||
|
||||
"people.kind.org": "org",
|
||||
"people.kind.team": "team",
|
||||
"people.kind.project": "project",
|
||||
"people.kind.user": "person",
|
||||
"people.close": "Close",
|
||||
"people.in": "In",
|
||||
"people.on_own": "On their own: in no organization, team or project.",
|
||||
|
||||
"people.limits": "Limits",
|
||||
"people.budgets_unpriced": "No model has a price, so budgets stay at zero; rate and token limits apply.",
|
||||
"people.rpm": "Requests/min",
|
||||
"people.tpm": "Tokens/min",
|
||||
"people.budget": "Budget",
|
||||
"people.spent": "Spent, %s",
|
||||
"people.models": "Models",
|
||||
"people.models_only": "only <code>%s</code>",
|
||||
"people.models_limited_by": "limited by %s",
|
||||
"people.models_every": "every model",
|
||||
"people.each_member": "Each member",
|
||||
"people.change_limits": "change limits",
|
||||
"people.set_limits": "set limits",
|
||||
"people.quota_for": "Quota for %s",
|
||||
"people.note.shared": "Shared by everything beneath it.",
|
||||
"people.limit_each_member": "limit each member",
|
||||
"people.each_member_of": "Each member of %s, separately",
|
||||
"people.note.each_member": "Every member gets this much here, counted apart from the others.",
|
||||
"people.everywhere": "%s, everywhere",
|
||||
"people.note.person": "Across every key this person holds, every project they use and the chat page.",
|
||||
|
||||
"people.capacity": "Capacity",
|
||||
"people.cores": "Cores",
|
||||
"people.memory_gb": "Memory GB",
|
||||
"people.capacity_note": "Held of guaranteed (at most the limit).",
|
||||
"people.change": "change",
|
||||
"people.no_capacity": "No capacity quota: its models share the cluster by their class alone.",
|
||||
"people.set_one": "set one",
|
||||
|
||||
"people.use": "Use, %s",
|
||||
"people.n_requests": "%s requests",
|
||||
"people.n_tokens": "%s tokens",
|
||||
"people.spent_amount": "%s spent",
|
||||
"people.n_errors": "%s errors",
|
||||
"people.error_rate": "Error rate",
|
||||
"people.refused": "Refused by a limit",
|
||||
"people.first_token": "First token",
|
||||
"people.latency": "Latency",
|
||||
"people.chart.requests": "Requests",
|
||||
"people.chart.tokens": "Tokens",
|
||||
"people.chart.spend": "Spend",
|
||||
"people.nothing_used": "Nothing used in this window.",
|
||||
|
||||
"users.pending.title": "Waiting for approval",
|
||||
"users.col.email": "Email",
|
||||
"users.col.name": "Name",
|
||||
"users.col.signed_up": "Signed up",
|
||||
"users.col.approve_into": "Approve, and put them in",
|
||||
"users.project_for": "Project for %s",
|
||||
"users.no_project": "No project yet: on their own",
|
||||
"users.approve": "Approve",
|
||||
"users.reject_confirm": "Reject and remove %s?",
|
||||
"users.reject": "Reject",
|
||||
|
||||
"users.tree.title": "Organizations, teams and projects",
|
||||
"users.tree.note": "A person is in a project, a project in a team, a team in an organization. Choose a name — or a person in the list below — to see its limits, capacity and use beside the tree; open a project for its people and keys.",
|
||||
"users.tree.note_quotas": "A person is in a project, a project in a team, a team in an organization, and <a href=\"/admin/quotas\">limits</a> can be set on any of them. Choose a name — or a person in the list below — to see its limits, capacity and use beside the tree; open a project for its people and keys.",
|
||||
"users.empty": "No organizations yet. Create one, then a team in it, then a project in the team — an account can only be approved into a project.",
|
||||
"users.n_teams.one": "1 team",
|
||||
"users.n_teams.other": "%d teams",
|
||||
"users.rename": "rename",
|
||||
"users.new_name": "New name",
|
||||
"users.remove_org_confirm": "Remove the organization %s?",
|
||||
"users.remove": "remove",
|
||||
"users.n_projects.one": "1 project",
|
||||
"users.n_projects.other": "%d projects",
|
||||
"users.move": "move",
|
||||
"users.move_team_to": "Organization to move %s to",
|
||||
"users.move_button": "Move",
|
||||
"users.remove_team_confirm": "Remove the team %s?",
|
||||
"users.limits_of": "Limits and use of %s",
|
||||
"users.limits_and_use": "limits & use",
|
||||
"users.n_members.one": "1 member",
|
||||
"users.n_members.other": "%d members",
|
||||
"users.n_keys.one": "1 key",
|
||||
"users.n_keys.other": "%d keys",
|
||||
"users.n_revoked": "(%d revoked)",
|
||||
"users.move_project": "move to another team",
|
||||
"users.move_project_to": "Team to move %s to",
|
||||
"users.remove_project_confirm": "Remove the project %s?",
|
||||
"users.not_removable": "Removable once it has no people and never had a key.",
|
||||
"users.col.member": "Member",
|
||||
"users.col.state": "State",
|
||||
"users.col.last_sign_in": "Last sign-in",
|
||||
"users.no_members": "No members of its own. Members of its team or organization can use it too.",
|
||||
"users.col.key": "Key",
|
||||
"users.col.owner": "Owner",
|
||||
"users.col.last_used": "Last used",
|
||||
"users.issued_by_operator": "issued by an operator",
|
||||
"users.key_state.active": "active",
|
||||
"users.key_state.revoked": "revoked",
|
||||
"users.key_state.expired": "expired",
|
||||
"users.revoke_confirm": "Revoke the key %s? Whatever uses it stops working.",
|
||||
"users.revoke": "revoke",
|
||||
"users.add_project": "+ project",
|
||||
"users.new_project_in": "new project in %s",
|
||||
"users.new_project_in_label": "New project in %s",
|
||||
"users.add_team": "+ team",
|
||||
"users.new_team_in": "new team in %s",
|
||||
"users.new_team_in_label": "New team in %s",
|
||||
"users.new_org": "new organization",
|
||||
"users.new_org_label": "New organization",
|
||||
"users.add_org": "Add organization",
|
||||
|
||||
"users.accounts": "All accounts",
|
||||
"users.col.person": "Person",
|
||||
"users.col.member_of": "Member of",
|
||||
"users.col.add_to": "Add to",
|
||||
"users.alone_note": "Someone who is a member of nothing uses the gateway on their own: personal keys and the chat page, within their personal limits.",
|
||||
"users.no_accounts": "No accounts yet.",
|
||||
"users.shell_note": "Not here on purpose: making someone an admin, resetting a password and issuing a key for someone else. Each turns a stolen admin session into more access than it had, so they need a shell on the control plane — <code>llmgw users promote</code>, <code>llmgw users reset-password</code>, <code>llmgw keys create</code>.",
|
||||
|
||||
"users.more_for": "More for %s",
|
||||
"users.more": "More",
|
||||
"users.suspend": "suspend",
|
||||
"users.reinstate": "reinstate",
|
||||
"users.remove_confirm": "Remove %s for good? Their keys are revoked.",
|
||||
"users.you": "you",
|
||||
"users.tag.admin": "admin",
|
||||
"users.state.active": "active",
|
||||
"users.state.pending": "pending",
|
||||
"users.state.suspended": "suspended",
|
||||
"users.role.manager": "manager",
|
||||
"users.stop_managing_of": "Stop managing %s",
|
||||
"users.make_manager_of": "Make manager of %s: can see its page",
|
||||
"users.stop_managing": "Stop managing",
|
||||
"users.make_manager": "Make manager",
|
||||
"users.take_out_confirm": "Take %s out of %s? Keys they made there are revoked.",
|
||||
"users.take_out_of": "Take out of %s",
|
||||
"users.on_own": "on their own",
|
||||
"users.add_to_label": "Add %s to",
|
||||
"users.choose": "choose…",
|
||||
"users.also_in": "also in %d more",
|
||||
"users.take_out_here_confirm": "Take %s out of here? Keys they made here are revoked.",
|
||||
"users.take_out": "take out",
|
||||
"users.person_to_add": "Person to add",
|
||||
"users.choose_person": "choose a person…",
|
||||
"users.add_member": "Add member",
|
||||
"users.nobody_in": "Nobody is a member of %s itself; a member here could use every project in it.",
|
||||
|
||||
"users.msg.about": "%s %s",
|
||||
"users.msg.approved_alone": "approved, on their own for now",
|
||||
"users.msg.approved_into": "approved into %s",
|
||||
"users.msg.suspended": "suspended, and signed out everywhere",
|
||||
"users.msg.reinstated": "reinstated",
|
||||
"users.msg.added_to": "added to %s",
|
||||
"users.msg.now_manages": "now manages %s",
|
||||
"users.msg.no_longer_manages": "no longer manages %s",
|
||||
"users.msg.removed_from": "removed from %s",
|
||||
"users.msg.removed_from_one_key": "removed from %s; 1 key they made there revoked",
|
||||
"users.msg.removed_from_keys": "removed from %s; %d keys they made there revoked",
|
||||
"users.msg.removed_keys_revoked": "removed; their keys are revoked",
|
||||
"users.msg.org_created": "organization %s created",
|
||||
"users.msg.team_created": "team %s created",
|
||||
"users.msg.project_created": "project %s created",
|
||||
"users.msg.renamed": "renamed to %s",
|
||||
"users.msg.team_moved": "team moved",
|
||||
"users.msg.project_moved": "project moved to %s",
|
||||
"users.msg.removed": "removed",
|
||||
"users.msg.member_added": "%s added to %s",
|
||||
"users.msg.key_revoked": "key revoked; a running server stops accepting it when its cache expires",
|
||||
|
||||
"users.err.about": "%s: %s",
|
||||
"users.err.self_suspend": "you cannot suspend your own account",
|
||||
"users.err.choose_where": "choose where they go",
|
||||
"users.err.manager_role": "a manager is a role in an organization",
|
||||
"users.err.nothing_to_leave": "nothing to leave",
|
||||
"users.err.self_remove": "you cannot remove your own account",
|
||||
"users.err.choose_org": "choose the organization this team belongs to",
|
||||
"users.err.choose_team": "choose the team this project belongs to",
|
||||
"users.err.no_such_kind": "nothing of that kind",
|
||||
"users.err.choose_destination": "choose where it goes",
|
||||
"users.err.only_team_project_moves": "only a team or a project moves",
|
||||
"users.err.choose_who": "choose who to add",
|
||||
"users.err.duplicate": "there is already one of those here",
|
||||
"users.err.not_empty": "not removed: %s",
|
||||
"users.err.gone": "that is no longer there; the page was out of date",
|
||||
"users.err.read_projects": "could not read the projects",
|
||||
"users.err.read_accounts": "could not read the accounts",
|
||||
"users.err.read_memberships": "could not read the memberships",
|
||||
"users.err.read_keys": "could not read the keys",
|
||||
"users.err.read_tree": "could not read the tree",
|
||||
"users.err.read_orgs": "could not read the organizations",
|
||||
"users.err.read_teams": "could not read the teams"
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"quotas.unpriced": "No model has a price, so nothing is charged and budgets stay at zero however much is used. Rate and token limits still apply. Set prices in the <code>pricing</code> block of the server configuration to make budgets bind.",
|
||||
|
||||
"quotas.since": "30 days",
|
||||
"quotas.col.where": "Where",
|
||||
"quotas.col.rpm": "Requests/min",
|
||||
"quotas.col.tpm": "Tokens/min",
|
||||
"quotas.col.budget": "Budget",
|
||||
"quotas.col.spent": "Spent",
|
||||
"quotas.col.person": "Person",
|
||||
|
||||
"quotas.kind.org": "org",
|
||||
"quotas.kind.team": "team",
|
||||
"quotas.kind.project": "project",
|
||||
"quotas.kind.user": "user",
|
||||
"quotas.kind.org-member": "org-member",
|
||||
"quotas.kind.team-member": "team-member",
|
||||
"quotas.kind.project-member": "project-member",
|
||||
|
||||
"quotas.only_models": "only <code>%s</code>",
|
||||
"quotas.models_from": "models limited by %s",
|
||||
"quotas.each_member": "each member here: %s",
|
||||
"quotas.pct_of_budget": "%d%% of its budget",
|
||||
"quotas.pct": "%d%%",
|
||||
"quotas.close": "close",
|
||||
"quotas.change": "change",
|
||||
"quotas.set": "set",
|
||||
"quotas.per_member": "per member",
|
||||
"quotas.per_member_title": "A limit each member here has separately",
|
||||
|
||||
"quotas.member_form.title": "Each member of %s, separately",
|
||||
"quotas.member_form.note": "Every member gets this much here, counted apart from the others — a fair share rather than one pool.",
|
||||
|
||||
"quotas.form.title": "Quota for <strong>%s</strong>, shared by everything beneath it. A blank field means no limit of that kind, not a limit of zero.",
|
||||
"quotas.form.rpm": "Requests / minute",
|
||||
"quotas.form.tpm": "Tokens / minute",
|
||||
"quotas.form.up_from": "%s from %s",
|
||||
"quotas.form.per": "per",
|
||||
"quotas.per.month": "month",
|
||||
"quotas.per.day": "day",
|
||||
"quotas.form.models": "Only these models",
|
||||
"quotas.form.models_hint": "comma separated; blank allows all",
|
||||
"quotas.form.every_model": "every model",
|
||||
"quotas.form.remove_all": "Remove every limit here",
|
||||
|
||||
"quotas.legend": "<span class=\"q-up\">60 ↑</span> is held by a limit set higher up — hover it to see where. A value set here is in normal type.",
|
||||
"quotas.empty_tree": "Nothing to give a quota to yet. Make an organization on the <a href=\"/admin/users\">Organizations & people</a> tab first.",
|
||||
|
||||
"quotas.people.title": "People",
|
||||
"quotas.people.note": "A person's own limit covers everything they do, across all their keys, projects and the chat page.",
|
||||
"quotas.person_form.title": "%s, everywhere",
|
||||
"quotas.person_form.note": "Across every key this person holds, every project they use and the chat page.",
|
||||
"quotas.people.empty": "Nobody has been approved yet.",
|
||||
|
||||
"quotas.explain.summary": "How limits combine",
|
||||
"quotas.explain.chain": "Along a branch the most restrictive value wins, so a team cannot raise what its organization set and a project cannot raise its team's. Only an organization's own limit is bounded by nothing. Model lists intersect: a project cannot allow a model its team withheld.",
|
||||
"quotas.explain.keys": "Per-key limits are not set here: a key is bounded by its project, and putting every caller's throughput one click from an admin session is more than this page should do — <code>llmgw keys limits --scope key</code>.",
|
||||
|
||||
"quotas.qform.title": "<strong>%s</strong>. %s A blank field means no limit of that kind.",
|
||||
"quotas.qform.remove": "Remove these limits",
|
||||
"quotas.set_on": "set on %s",
|
||||
|
||||
"quotas.capped.rpm": "%s requests/min",
|
||||
"quotas.capped.tpm": "%s tokens/min",
|
||||
"quotas.capped.budget": "a budget of %s",
|
||||
"quotas.and": " and ",
|
||||
"quotas.shadowed.above": "above it, %s still apply",
|
||||
"quotas.shadowed.by": "%s caps this at %s",
|
||||
|
||||
"quotas.desc.rpm": "rpm %s",
|
||||
"quotas.desc.tpm": "tpm %s",
|
||||
"quotas.desc.budget": "budget %s",
|
||||
"quotas.desc.models": "models %s",
|
||||
"quotas.desc.none": "no limits",
|
||||
|
||||
"quotas.personal.off": "Personal use is off: people use the gateway only through their projects.",
|
||||
"quotas.personal.none": "Personal use has no default limit: someone with no limit of their own is bounded only by the cluster-wide defaults.",
|
||||
"quotas.personal.held": "With no limit of their own, a person's personal use is held to %s.",
|
||||
|
||||
"quotas.msg.cleared": "%s %s: every limit here removed",
|
||||
"quotas.msg.saved": "%s %s: %s",
|
||||
"quotas.err.scope": "a quota belongs to an organization, team, project or person",
|
||||
"quotas.err.which": "which one?",
|
||||
"quotas.err.rpm": "requests per minute must be a whole number, or empty for no limit",
|
||||
"quotas.err.tpm": "tokens per minute must be a whole number, or empty for no limit",
|
||||
"quotas.err.budget": "the budget must be a number, or empty for no budget",
|
||||
"quotas.err.period": "a budget needs a period: day or month",
|
||||
"quotas.err.read_limits": "could not read the limits",
|
||||
"quotas.err.read_tree": "could not read the organizations",
|
||||
|
||||
"capacity.title": "Capacity",
|
||||
"capacity.note": "What a part of the tree is guaranteed of the cluster, and the most it may hold: GPUs, CPU cores and memory, counted from the replicas of the models it owns and of those beneath it. What it holds within its guarantee is never taken for someone else's model; beyond it, it borrows free room and gives it back when asked. A model's owner is set on its card on the mirror page.",
|
||||
"capacity.col.cores": "Cores",
|
||||
"capacity.col.memory": "Memory GB",
|
||||
"capacity.col.set_by": "Set by",
|
||||
"capacity.remove": "remove",
|
||||
"capacity.legend": "Each figure is held of guaranteed (at most the limit); — is not governed.",
|
||||
"capacity.empty": "No capacity quotas: every model shares the cluster by its class alone.",
|
||||
"capacity.form.title_change": "<strong>Change a capacity quota.</strong> A blank field is not governed; 0 is none at all.",
|
||||
"capacity.form.title_set": "<strong>Set a capacity quota.</strong> A blank field is not governed; 0 is none at all.",
|
||||
"capacity.form.choose": "choose…",
|
||||
"capacity.form.g_gpus": "Guaranteed GPUs",
|
||||
"capacity.form.l_gpus": "At most GPUs",
|
||||
"capacity.empty_tree": "Nothing to give a capacity quota to yet. Make an organization on the <a href=\"/admin/users\">Organizations & people</a> tab first.",
|
||||
|
||||
"capacity.cell.of": "%s of %s",
|
||||
"capacity.cell.at_most": "%s (at most %s)",
|
||||
|
||||
"capacity.msg.removed": "capacity quota removed from %s",
|
||||
"capacity.msg.set": "capacity quota set on %s; it takes effect within half a minute",
|
||||
"capacity.err.scope": "choose where the quota applies",
|
||||
"capacity.err.gpus": "guaranteed GPUs must be a whole number, or blank for none",
|
||||
"capacity.err.cores": "guaranteed cores must be a whole number, or blank for none",
|
||||
"capacity.err.memory": "guaranteed memory must be a whole number, or blank for none",
|
||||
"capacity.err.limit_gpus": "the GPU limit must be a whole number, or blank for none",
|
||||
"capacity.err.limit_cores": "the core limit must be a whole number, or blank for none",
|
||||
"capacity.err.limit_memory": "the memory limit must be a whole number, or blank for none"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"serve.refusal": "Cannot be served as it stands: %s.",
|
||||
"serve.serving": "Serving <code>%s</code> with <strong>%s</strong>.",
|
||||
"serve.whole_version": "The whole version, %d files: the node fetches them into one directory and the engine is given the directory.",
|
||||
"serve.name": "Name",
|
||||
"serve.aliases": "Aliases",
|
||||
"serve.aliases_hint": "comma separated",
|
||||
"serve.aliases_example": "coder-7b",
|
||||
"serve.replicas": "Replicas",
|
||||
"serve.runs": "Runs",
|
||||
"serve.mode.ondemand": "on demand: loads when asked, stops after 15 min unused",
|
||||
"serve.description": "What it is for",
|
||||
"serve.description_hint": "shown to clients in /v1/models",
|
||||
"serve.description_example": "general chat, strong Swedish; 14B, slower than the 8B",
|
||||
"serve.context_length": "Context a request may use",
|
||||
"serve.max_output": "Output limit",
|
||||
"serve.optional": "optional",
|
||||
"serve.ctx_pool": "Engine context pool",
|
||||
"serve.ctx_pool_hint": "divided across slots",
|
||||
"serve.placement": "Runs on",
|
||||
"serve.place.gpu": "GPU: %s, %d of %d free",
|
||||
"serve.place.cpu_slower": "CPU only: no GPU, many times slower",
|
||||
"serve.place.cpu_only": "CPU: this fleet has no GPU",
|
||||
"serve.gpus": "GPUs per replica",
|
||||
"serve.gpus_hint": "on one node",
|
||||
"serve.parallel": "Parallel requests",
|
||||
"serve.parallel_hint": "per replica",
|
||||
"serve.cpu_ok": "Run on the CPU anyway: %d GPUs are free and would sit unused",
|
||||
"serve.memory": "Needs RAM (GB)",
|
||||
"serve.memory_hint": "for the context pool beside it",
|
||||
"serve.cpu_cores": "CPU cores",
|
||||
"serve.cpu_cores_hint": "reserved; the engine's threads",
|
||||
"serve.selector": "Only nodes labelled",
|
||||
"serve.selector_hint": "key / value, optional",
|
||||
"serve.selector_key_example": "role",
|
||||
"serve.selector_value_example": "cpu-large",
|
||||
"serve.add": "Add to the catalog",
|
||||
|
||||
"serve.on_cpu": "%s on the CPU",
|
||||
"serve.on_gpu_one": "%s on 1 GPU %s",
|
||||
"serve.on_gpus": "%s on %d %s GPUs of one node",
|
||||
"serve.host_memory": "%s GB of host memory",
|
||||
"serve.cores.one": "%d core",
|
||||
"serve.cores.other": "%d cores",
|
||||
"serve.pool_parallel": "context pool %s over %s parallel requests (%s each)",
|
||||
"serve.pool": "context pool %s",
|
||||
"serve.will_run_as": "Will run as: %s.",
|
||||
"serve.free_gpus_warning": "%s GPUs are free; on the CPU this runs many times slower.",
|
||||
|
||||
"serve.pool_note": "The context pool is what the engine is given; llama.cpp divides it across its parallel slots, so the number one request may use is the smaller one above.",
|
||||
"serve.memory_note": "Memory: %s. Change the context pool and this changes with it, by the KV cache's share — an estimate, and editable. It is filled in rather than left blank because a CPU profile has no card to be short of: with no figure, every ready node is a candidate and the first one picked is whichever the scheduler reached, not whichever can hold the model. A node label does the same job by name if you would rather pin it.",
|
||||
"serve.basis.estimate": "weights plus half again plus two",
|
||||
"serve.basis.shape": "weights %s GB + KV cache %s GB for %s tokens (%d layers × %d KV heads) + %s GB for the engine",
|
||||
|
||||
"serve.refuse.no_engine": "nothing here reads %s files",
|
||||
"serve.refuse.llamacpp": "llama.cpp on the nodes cannot run this: %s",
|
||||
"serve.refuse.declared": "a model called %s is declared in configuration; edit it there rather than adding a second",
|
||||
"serve.warn.not_installed": "no node reports having %s installed, and %s weights need it",
|
||||
"serve.warn.no_gpu": "This fleet has no GPU either; a GGUF build of the same model runs on the CPU nodes",
|
||||
"serve.warn.too_big": "the largest node has %d GB and this needs about %d",
|
||||
"serve.warn.and": "%s, and %s",
|
||||
|
||||
"serve.err.name_source": "a model needs a name and a source",
|
||||
"serve.err.whole_number": "%s must be a whole number",
|
||||
"serve.what.context_length": "the context length",
|
||||
"serve.what.max_output": "the output limit",
|
||||
"serve.what.replicas": "the replica count",
|
||||
"serve.what.gpus": "the GPUs per replica",
|
||||
"serve.what.ctx_pool": "the engine's context pool",
|
||||
"serve.what.parallel": "the parallel requests",
|
||||
"serve.what.memory": "the memory requirement",
|
||||
"serve.what.cores": "the cores",
|
||||
"serve.err.engine": "which engine should run it?",
|
||||
"serve.err.needs_gpu": "%s runs on GPUs; choose a GPU class to place it on",
|
||||
"serve.err.placement": "%q is not a placement",
|
||||
"serve.err.cpu_with_free_gpus.one": "this fleet has %d GPU free and %s was set to run on the CPU; choose a GPU placement, or tick \"run on the CPU anyway\"",
|
||||
"serve.err.cpu_with_free_gpus.other": "this fleet has %d GPUs free and %s was set to run on the CPU; choose a GPU placement, or tick \"run on the CPU anyway\"",
|
||||
"serve.msg.added": "%s added to the catalog, %s: %s; it is placed on the next pass",
|
||||
"serve.replicas_n.one": "%d replica",
|
||||
"serve.replicas_n.other": "%d replicas"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"usage.group_by": "Group by",
|
||||
"usage.window": "Window",
|
||||
"usage.group.org": "organization",
|
||||
"usage.group.team": "team",
|
||||
"usage.group.project": "project",
|
||||
"usage.group.user": "person",
|
||||
"usage.group.model": "model",
|
||||
"usage.window.24h": "last 24 hours",
|
||||
"usage.window.7d": "last 7 days",
|
||||
"usage.window.30d": "last 30 days",
|
||||
|
||||
"usage.requests": "Requests",
|
||||
"usage.requests_note": "Requests served in each slice, counted where the key belongs now rather than where it was at the time.",
|
||||
"usage.tokens": "Tokens",
|
||||
"usage.tokens_note": "Prompt and completion together, which is the figure a person recognises as what they consumed.",
|
||||
"usage.spend": "Spend",
|
||||
"usage.spend_note": "In whole units, the same ones a budget is written in.",
|
||||
"usage.unpriced": "No model has a price, so every request costs zero and this chart would be a flat line however busy the cluster is. Set prices in the <code>pricing</code> block of the server configuration.",
|
||||
|
||||
"usage.col.group": "Group",
|
||||
"usage.errors": "Errors",
|
||||
"usage.col.error_rate": "Error rate",
|
||||
"usage.col.error_rate_title": "Failed requests, refusals by a limit included",
|
||||
"usage.col.limited": "Limited",
|
||||
"usage.col.limited_title": "Refused by a rate, token or budget limit",
|
||||
"usage.col.ttft": "First token p50 / p95",
|
||||
"usage.col.ttft_title": "Time to first token, for streamed requests",
|
||||
"usage.col.latency": "Latency p95",
|
||||
"usage.pct": "%s%%",
|
||||
"usage.no_owner_keys": "keys with no owner",
|
||||
"usage.unknown": "unknown",
|
||||
"usage.empty": "Nothing recorded in this window.",
|
||||
|
||||
"usage.gpu.title": "GPU time, by owner",
|
||||
"usage.gpu.chart": "GPUs held, by owner",
|
||||
"usage.gpu.chart_note": "The cards held by the replicas of each owner's models: models whose owner is a part of the quota tree.",
|
||||
"usage.col.owner": "Owner",
|
||||
"usage.col.gpu_hours": "GPU-hours",
|
||||
"usage.col.held_now": "Held now",
|
||||
"usage.no_owner": "no owner",
|
||||
"usage.gpu.note": "From the metrics store, which keeps its history for as long as it is set to (fifteen days by default); a model with no owner is counted under “no owner”.",
|
||||
|
||||
"usage.note.charts": "The charts draw the four busiest groups by total and sum the rest into “other”, so they always add up to the whole cluster. The table below is every group, in the same order.",
|
||||
"usage.note.person": "A person is not a level of the quota tree — one person holds keys in several projects — so grouping by person crosses the tree rather than naming a level of it. Quotas are written on the other three, on the <a href=\"/admin/quotas\">Limits</a> tab.",
|
||||
|
||||
"usage.err.read": "could not read usage"
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"about.meta.description": "llmgw, modelleri kendi GPU'larınızda çalıştırır ve OpenAI uyumlu tek bir API'nin arkasından sunar.",
|
||||
"about.home_label": "llmgw ana sayfa",
|
||||
"about.nav.label": "Bölümler",
|
||||
"about.nav.what": "Ne yapar",
|
||||
"about.nav.fabrics": "Fabric'ler",
|
||||
"about.nav.install": "Kurulum",
|
||||
"about.nav.proxy": "Ters proxy",
|
||||
"about.nav.download": "İndir",
|
||||
"about.nav.source": "Kaynak kod",
|
||||
"about.nav.gateway": "Bu ağ geçidi",
|
||||
|
||||
"about.hero.eyebrow": "Kendi sunucunuzda çalışan LLM ağ geçidi · %s",
|
||||
"about.hero.title": "<em>Kendi GPU'larınızın</em> önünde tek bir uç nokta.",
|
||||
"about.hero.lede": "llmgw; vLLM, SGLang ve llama.cpp'yi sunucularınızda çalıştırır ve hepsini OpenAI uyumlu tek bir API'nin arkasından sunar. Hangi GPU'ların aynı NVLink alanını ya da aynı InfiniBand fabric'ini paylaştığını bilir; makinelere bölünmüş bir model hiçbir zaman iki fabric'e birden yayılmaz.",
|
||||
"about.hero.download": "İndir",
|
||||
"about.hero.guide": "Kurulum kılavuzu",
|
||||
"about.hero.oneline_label": "Bir kontrol düzlemi kurun",
|
||||
"about.copy": "Kopyala",
|
||||
"about.copied": "Kopyalandı",
|
||||
"about.selected": "Seçildi",
|
||||
"about.schem.title": "İstemciler tek bir llmgw adresine ulaşır. llmgw bir modelin parçalarını her seferinde tek bir fabric içindeki GPU'lara yerleştirir.",
|
||||
"about.schem.editor": "editör ajanı",
|
||||
"about.schem.caption": "Birden çok düğüme yayılan bir replika, yeri varsa tek bir NVLink alanına, yoksa tek bir InfiniBand fabric'ine yerleşir. Birbirini göremeyen iki fabric arasında asla köprü kurulmaz: all-reduce takılıp kalırdı.",
|
||||
|
||||
"about.what.title": "Ne yapar",
|
||||
"about.what.intro": "Tek bir statik ikili dosya. Kontrol düzlemi olarak, her GPU düğümünde ajan olarak ve ağırlık aynası olarak çalışır.",
|
||||
"about.what.api.title": "OpenAI API'si, sizin donanımınız",
|
||||
"about.what.api.text": "Herhangi bir OpenAI istemcisini ona yönlendirin; değişen yalnızca temel URL'dir. Akış, araç çağrıları, <code>/v1/models</code> ve <code>/v1/responses</code> istemcinizin beklediği gibi çalışır.",
|
||||
"about.what.placement.title": "Fabric'i tanıyan yerleştirme",
|
||||
"about.what.placement.text": "Ajanlar GPU sınıfını, belleği, NVLink eşlerini ve InfiniBand portlarını bildirir. Replikalar — tek bir makineye sığmayanlar da dahil — sığdıkları yere, en çok NVLink çiftine sahip GPU'lara yerleşir.",
|
||||
"about.what.ondemand.title": "Modeller istendiğinde yüklenir",
|
||||
"about.what.ondemand.text": "Katalogda GPU'larınızın alabileceğinden fazla model tutun. İlk istek modeli başlatır, sohbet sayfası yüklenişini gösterir ve boşta kalma süresi dolunca model yeniden durur.",
|
||||
"about.what.keys.title": "Anahtarlar, bütçeler, hız sınırları",
|
||||
"about.what.keys.text": "Kuruluşlar, ekipler ve projeler; her birinin kendi sınırları var. Küme dolduğunda kimin bekleyeceğine öncelik bantları karar verir. Yöneticiler kuruluşlarının harcamasını, kişiler kendi harcamalarını görür.",
|
||||
"about.what.editors.title": "Editörler hesap sayfasından bağlanır",
|
||||
"about.what.editors.text": "Continue, Codex ya da Cline'ı ve bir model seçin. Sayfa bir anahtar oluşturur ve yapılandırmayı doldurulmuş olarak ya da onu yazan tek satırlık bir komut olarak verir.",
|
||||
"about.what.chat.title": "Hızlı sorular için bir sohbet sayfası",
|
||||
"about.what.chat.text": "Markdown ve matematik biçimlenmiş gösterilir, yanıt yarıda durdurulabilir; saniyede token ve ilk token süresi görünür, bir sistem istemi ve sıcaklık ayarlanabilir. Ağ geçidi hiçbir sohbeti saklamaz.",
|
||||
"about.what.mirror.title": "Göz atabileceğiniz bir ayna",
|
||||
"about.what.mirror.text": "Hugging Face'te öne çıkanları keşfedin, bir modelin kartını kendi satırının altında okuyun ve tüm küme için bir kez indirin. Varsayılan olarak yalnızca düğümlerinizin çalıştırabildikleri gösterilir.",
|
||||
"about.what.engines.title": "Düğümler kendi motorlarını kurar",
|
||||
"about.what.engines.text": "Her düğüme llama.cpp; çalıştırabilen her GPU düğümüne de PyPI'dan, her dosyasının özeti doğrulanarak vLLM kurulur. Çalıştıramayan düğüm nedenini söyler.",
|
||||
"about.what.upgrades.title": "Modelleri çalışır halde tutan yükseltmeler",
|
||||
"about.what.upgrades.text": "Önce kontrol düzlemini, sonra ajanları yükseltin. Yeniden başlatılan bir ajan, çalıştırdığı motorları yeniden yüklemek yerine devralır; böylece replikalar baştan sona ayakta kalır.",
|
||||
"about.what.dashboard.title": "Hiçbir kurulum gerektirmeyen bir pano",
|
||||
"about.what.dashboard.text": "<code>/admin</code>; GPU sınıfı başına kapasiteyi, düğüm başına CPU ve belleği, kimin neyi kullandığını, her düğümün sayfasını ve günlüklerini gösterir. Telefona sığar. Prometheus isteğe bağlıdır.",
|
||||
|
||||
"about.fabrics.title": "InfiniBand ve NVLink, her birinden birkaç tane",
|
||||
"about.fabrics.intro": "Bir sahada bağımsız InfiniBand fabric'leri, sekiz GPU'lu HGX kartları ve NVL72 kabinleri yan yana bulunabilir. llmgw bunları bir yapılandırma dosyasındaki adlarına göre değil, adaptörlerin bildirdiklerine göre ayırt eder.",
|
||||
"about.fabrics.guide": "Fabric ve NVLink kılavuzu",
|
||||
"about.fabrics.order_label": "Düğümlere yayılan bir replikanın yerleştirileceği yerler, sırasıyla",
|
||||
"about.fabrics.nvlink.title": "Tek bir NVLink alanı",
|
||||
"about.fabrics.nvlink.text": "NVL72 ya da NVLink Switch. Bütün kartlar katılmış, IMEX ayakta. Parçalar <code>NCCL_MNNVL_ENABLE=1</code> ile çalışır.",
|
||||
"about.fabrics.ib.title": "Tek bir InfiniBand ya da RoCE fabric'i",
|
||||
"about.fabrics.ib.text": "Önce aynı leaf anahtarına bağlı düğümler, en son kablosu bozuk düğümler.",
|
||||
"about.fabrics.never.title": "Asla ikisine birden",
|
||||
"about.fabrics.never.text": "Aynı ad verilmiş iki fabric iki ayrı fabric olarak ele alınır ve küme sayfası bunu belirtir.",
|
||||
"about.fabrics.doctor": "Bir düğümü tek komutla denetleyin",
|
||||
"about.fabrics.doctor_label": "llmgw doctor çıktısından bir örnek",
|
||||
"about.fabrics.sm.title": "Subnet yöneticileri izlenir",
|
||||
"about.fabrics.sm.text": "Her fabric için ana yönetici, öncelik, yedekler ve devralmalar. Yedeği olmayan fabric işaretlenir. llmgw kendisi subnet yöneticisi çalıştırmaz.",
|
||||
"about.fabrics.map.title": "Her fabric'in haritası",
|
||||
"about.fabrics.map.text": "<code>/admin/fabrics</code> anahtarları katmanlar halinde çizer; kabloları hızlarına göre renklendirir, bozuk olanları kırmızıyla gösterir ve adaptörlerinizdeki canlı trafiği yansıtır.",
|
||||
"about.fabrics.racks.title": "Kabin olarak NVLink alanları",
|
||||
"about.fabrics.racks.text": "<code>/admin/nvlink</code> hangi tepsilerin katıldığını, hangisinin IMEX'inin çöktüğünü, hangi bağlantının düşük hızda çalıştığını gösterir.",
|
||||
"about.fabrics.ports.title": "Portlar her yaşam sinyalinde yeniden okunur",
|
||||
"about.fabrics.ports.text": "Kapanan bir port, saniyeler içinde fabric'ini düğümden düşürür. Sayaçlar ve hızlar Prometheus'a aktarılır.",
|
||||
|
||||
"about.install.title": "Sıfırdan ilk isteğe",
|
||||
"about.install.intro": "Debian, Ubuntu ya da RHEL 10. NVIDIA sürücülerini ve InfiniBand yığınını siz kurarsınız; gerisini llmgw yapar.",
|
||||
"about.install.guide": "<a href=\"https://gitea.mynodes.xyz/ilker/llmgw/src/branch/main/docs/install.md\">Ayrıntılı kılavuz</a> aynayı, GPU'ları ve bir şey çalışmadığında ne yapılacağını anlatır.",
|
||||
"about.install.binary.title": "İkili dosyayı her makineye koyun",
|
||||
"about.install.binary.text": "Statik, bağımlılıksız; her yerde aynı dosya.",
|
||||
"about.install.server.title": "Kontrol düzlemini kurun",
|
||||
"about.install.server.text": "Kullanıcıyı, sertifika yetkilisini, sertifikayı, <code>server.yaml</code> dosyasını ve yönetici hesabınızı oluşturur, ardından hizmeti başlatır. Yeniden çalıştırmak güvenlidir. Düğümlerin kullandığı ama makinenin kendi adı olarak göremediği her ad için <code>--host</code> ekleyin.",
|
||||
"about.install.node.title": "Bir düğüm ekleyin",
|
||||
"about.install.node.text": "Sertifika yetkilisini barındıran kontrol düzleminden. Düğüm sertifikasını, ikili dosyayı ve ajanı alır, motorunu da kendisi kurar.",
|
||||
"about.install.node.output_label": "llmgw ctl nodes çıktısından bir örnek",
|
||||
"about.install.model.title": "Bir model ekleyin ve istek gönderin",
|
||||
"about.install.model.text": "Yeniden başlatma gerekmez. Model yerleşmezse <code>llmgw ctl explain-placement</code> her düğümü ve neden geri çevrildiğini tek tek söyler.",
|
||||
|
||||
"about.proxy.title": "Ters proxy arkasında",
|
||||
"about.proxy.intro": "8080 portu düz HTTP'dir. Onu genel geçer bir sertifikayla Caddy ya da nginx arkasına koyun. Proxy'den yalnızca 8080 geçer: ajanlar 7443'e doğrudan ulaşır, çünkü orası kümenin kendi sertifika yetkilisiyle karşılıklı TLS'dir.",
|
||||
"about.proxy.loopback": "Proxy aynı makinedeyse llmgw'yi loopback adresine bağlayın: <code>listen: \"127.0.0.1:8080\"</code>.",
|
||||
"about.proxy.caddy": "Sertifikasını kendisi alır ve yeniler. <code>llm.example.com</code> yerine kendi adınızı yazın.",
|
||||
"about.proxy.nginx": "Sertifika <code>certbot --nginx</code> ile alınır. nginx 1.18 ve sonrasında çalışır.",
|
||||
"about.proxy.stream.title": "Akışlar arabelleğe alınmaz",
|
||||
"about.proxy.stream.text": "Token'lar sonda tek seferde değil, üretildikçe istemciye ulaşır.",
|
||||
"about.proxy.timeout.title": "Okuma zaman aşımı modelden uzundur",
|
||||
"about.proxy.timeout.text": "nginx'in 60 saniyelik varsayılanı, geç gelen bir ilk token'ı 504 hatasına çevirir.",
|
||||
"about.proxy.headers.title": "Yönlendirme başlıkları aktarılmaz, yeniden yazılır",
|
||||
"about.proxy.headers.text": "llmgw oturum çerezlerini <code>X-Forwarded-Proto</code> başlığına göre Secure olarak işaretler ve <code>X-Forwarded-Host</code> değerini temel URL olarak gösterir.",
|
||||
"about.proxy.body.title": "Gövde sınırı yönlendiricininkiyle aynıdır",
|
||||
"about.proxy.body.text": "32 MiB; böylece fazla büyük bir istek daha yüklenmeden reddedilir.",
|
||||
|
||||
"about.download.title": "İndir",
|
||||
"about.download.intro": "CI tarafından etiketten derlenir ve <code>checksums.txt</code> ile doğrulanır. Her sürüm, değişiklik günlüğüyle birlikte <a href=\"https://gitea.mynodes.xyz/ilker/llmgw/releases\">sürümler sayfasında</a> yer alır.",
|
||||
"about.download.released": "Yayın tarihi: %s",
|
||||
"about.download.col.for": "Platform",
|
||||
"about.download.col.file": "Dosya",
|
||||
"about.download.col.notes": "Notlar",
|
||||
"about.download.static_any": "Statik. Her libc ile çalışır.",
|
||||
"about.download.static": "Statik.",
|
||||
"about.download.nvml": "NVLink topolojisini NVML üzerinden görür. NVLink köprülü PCIe makineler içindir. Sürücü gerektirir.",
|
||||
"about.download.mac_arm": "macOS, Apple Silicon",
|
||||
"about.download.mac_note": "<code>llmgw ctl</code> için ve bir dizüstü bilgisayarda denemek için.",
|
||||
"about.download.proxy_note": "8080 portu için ters proxy.",
|
||||
"about.download.checksums": "Sağlama toplamları",
|
||||
"about.download.checksums_note": "Yukarıdaki her dosyanın SHA-256 özeti.",
|
||||
"about.download.dev": "Bu bir geliştirme derlemesidir (%s). Her dosyanın sağlama toplamıyla listelendiği <a href=\"https://gitea.mynodes.xyz/ilker/llmgw/releases\">sürümler sayfasından</a> bir sürüm edinin.",
|
||||
|
||||
"about.status.title": "Proje ne durumda",
|
||||
"about.status.intro": "1.0 öncesi: yapılandırma biçimleri ara sürümler arasında değişebilir. Aşağıda gerçek donanımda çalışmış olanlar ve henüz çalışmamış olanlar var.",
|
||||
"about.status.proven": "Gerçek kümelerde çalıştı",
|
||||
"about.status.proven.single": "Gerçek GPU'larda ve CPU'da tek düğümlü replikalar",
|
||||
"about.status.proven.ondemand": "İstek üzerine yükleme ve boşta kapanma",
|
||||
"about.status.proven.keys": "Anahtarlar, bütçeler, hız sınırları ve öncelik bantları",
|
||||
"about.status.proven.upgrades": "Hiçbir replikayı düşürmeden kademeli yükseltme",
|
||||
"about.status.proven.stores": "SQLite ve PostgreSQL depoları",
|
||||
"about.status.proven.chat": "Altı düğümlü bir test kümesinde sohbet, editör kurulumu ve Responses API",
|
||||
"about.status.open": "Henüz kanıtlanmadı",
|
||||
"about.status.open.fabrics": "Gerçek bir sahada InfiniBand ve NVLink: <code>sminfo</code>, <code>ibnetdiscover</code> ve <code>nvidia-smi</code> komutlarının gerçek çıktısına göre yazıldı ve sınandı, ama henüz o donanımda çalıştırılmadı. Önce birkaç düğümde <code>llmgw doctor --json</code> çalıştırın.",
|
||||
"about.status.open.planes": "Uzun süre boyunca iki kontrol düzlemi",
|
||||
"about.status.open.weights": "Düğümler indirilen ağırlıkları paylaşmaz; ayna her indirmeyi yerel tutar",
|
||||
|
||||
"about.footer.changelog": "Değişiklik günlüğü",
|
||||
"about.footer.reference": "Yapılandırma başvurusu"
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"signup.title": "Kaydol · llmgw",
|
||||
"signup.heading": "Hesap oluşturun",
|
||||
"signup.sub": "Hesabınız kullanılabilmeden önce bir operatör tarafından onaylanır.",
|
||||
"signup.thanks": "Teşekkürler. Hesabınız onay bekliyor.",
|
||||
"signup.no_email": "Bu ağ geçidi e-posta göndermez, dolayısıyla sizden bir e-posta gelmeyecek.\nErişim istediğiniz kişiye kaydolduğunuzu haber verin.",
|
||||
"signup.sign_in_once_approved": "Onaylandıktan sonra <a href=\"/login\">giriş yapın</a>.",
|
||||
"signup.already_had": "Bu adresle zaten bir hesap varsa yeni bir hesap açılmadı:\nmevcut hesapla <a href=\"/login\">giriş yapın</a>; parolasını unuttuysanız\nbir operatörden sıfırlamasını isteyin.",
|
||||
"signup.email": "E-posta",
|
||||
"signup.name": "Ad",
|
||||
"signup.password": "Parola",
|
||||
"signup.password_rule": "En az %d karakter. Tek kural uzunluk:\n uzun bir cümle, noktalama işaretleriyle süslenmiş kısa bir paroladan daha güçlüdür.",
|
||||
"signup.submit": "Kaydol",
|
||||
"signup.have_account": "Zaten hesabınız var mı? <a href=\"/login\">Giriş yapın</a>.",
|
||||
"signup.err.weak_password": "parola en az %d karakter olmalı",
|
||||
"signup.err.invalid_email": "bu bir e-posta adresine benzemiyor",
|
||||
"signup.err.internal": "bizim tarafımızda bir sorun oluştu ve hesabınız açılamadı. Daha sonra yeniden deneyin; sorun sürerse bir operatöre şu başvuru kodunu iletin: %s",
|
||||
|
||||
"login.title": "Giriş yap · llmgw",
|
||||
"login.heading": "Giriş yapın",
|
||||
"login.email": "E-posta",
|
||||
"login.password": "Parola",
|
||||
"login.submit": "Giriş yap",
|
||||
"login.with_sso": "%s ile giriş yap",
|
||||
"login.no_account": "Hesabınız yok mu? <a href=\"/signup\">Kaydolun</a>.",
|
||||
"login.err.form_expired": "formun süresi dolmuş; yeniden giriş yapın",
|
||||
"login.err.bad_credentials": "bu e-posta ve parola hiçbir hesapla eşleşmiyor",
|
||||
"login.err.not_approved": "bu hesap onay bekliyor",
|
||||
"login.err.suspended": "bu hesap askıya alındı",
|
||||
"login.err.too_many_tries": "çok fazla deneme yapıldı; biraz sonra yeniden deneyin",
|
||||
"login.err.busy": "aynı anda çok fazla giriş yapılıyor; birazdan yeniden deneyin",
|
||||
"login.err.try_again": "bir sorun oluştu; yeniden deneyin",
|
||||
"login.err.passwords_off": "bu ağ geçidinde parolayla değil, %s ile giriş yapılır",
|
||||
"login.err.rationed": "bu adresten çok fazla deneme yapıldı; bir dakika bekleyin",
|
||||
|
||||
"sso.default_label": "tek oturum açma (SSO)",
|
||||
"sso.err.no_answer": "giriş hizmeti yanıt vermedi; biraz sonra yeniden deneyin",
|
||||
"sso.err.failed": "kimlik sağlayıcı üzerinden giriş yapılamadı; baştan başlayın",
|
||||
"sso.err.no_email": "kimlik sağlayıcı bir e-posta adresi göndermedi; e-posta bilgisini (email claim) göndermesi için ayarlanması gerekir",
|
||||
"sso.err.unverified": "kimlik sağlayıcı %s adresini doğrulamamış",
|
||||
"sso.err.no_account": "bu ağ geçidinde %s için bir hesap yok; bir yöneticiden hesap isteyin",
|
||||
"sso.err.pending": "%s hesabı onay bekliyor",
|
||||
"sso.err.suspended": "%s hesabı askıya alınmış",
|
||||
"sso.err.state": "%s hesabının durumu: %s",
|
||||
|
||||
"profile.title": "Hesap · llmgw",
|
||||
"profile.heading": "Hesabınız",
|
||||
"profile.new_key": "Yeni anahtarınız",
|
||||
"profile.shown_once": "Bu anahtar yalnızca bir kez gösterilir. Yeniden üretilemez; sistemde yalnızca özeti (hash) saklanır.",
|
||||
"profile.store_it": "Anahtarı şimdi bir parola yöneticisine kaydedin.",
|
||||
"profile.copy_prompt": "Kopyalayın",
|
||||
|
||||
"connect.ready": "%s bağlanmaya hazır",
|
||||
"connect.shown_once": "Bunun için bir anahtar oluşturuldu ve <strong>yalnızca bu kez gösteriliyor</strong>; aşağıdaki her şeyin içinde yer alıyor. Yeniden üretilemez.",
|
||||
"connect.one_command": "Tek komutla",
|
||||
"connect.command_note": "Aşağıdaki dosyayı yazar; değiştirdiği her şeyin bir kopyasını saklar.",
|
||||
"connect.command_note_codex": "Aşağıdaki dosyayı Codex yapılandırmanıza yazar; değiştirdiği her şeyin bir kopyasını saklar.",
|
||||
"connect.by_hand": "Ya da elle: <code>%s</code>",
|
||||
"connect.fields": "Doldurulacak ayarlar",
|
||||
"connect.then": "Sonra",
|
||||
"connect.heading": "Editörünüzü bağlayın",
|
||||
"connect.intro": "Araca özel bir anahtar ve içinde bu anahtar, bu ağ geçidi ve bir model hazır olan yapılandırması.\nBağlantıyı kesmek için anahtarı aşağıdan iptal edin.",
|
||||
"connect.tool": "Araç",
|
||||
"connect.model": "Model",
|
||||
"connect.model_placeholder": "ağ geçidinin sunduğu bir model",
|
||||
"connect.spends_against": "Harcama yeri",
|
||||
"connect.submit": "Anahtarı ve yapılandırmayı oluştur",
|
||||
"connect.field.provider": "API sağlayıcısı (API Provider)",
|
||||
"connect.field.base_url": "Temel URL (Base URL)",
|
||||
"connect.field.key": "API anahtarı (API Key)",
|
||||
"connect.field.model": "Model kimliği (Model ID)",
|
||||
"connect.run.continue": "Continue'da, panelin üst kısmından \"llmgw\" asistanını seçin.",
|
||||
"connect.note.codex_blocks": "Komut, yukarıdaki iki bloğu ~/.codex/config.toml dosyasına ekler, daha önce eklediklerinin yerine koyar ve dosyanın önceki hâlinin bir kopyasını saklar.",
|
||||
"connect.note.codex_env": "Anahtarı komut satırına yazmamak için export LLMGW_API_KEY=… satırını kabuğunuzun profil dosyasına ekleyin.",
|
||||
"connect.run.cline": "Cline bu ayarları VS Code'un kendi gizli deposunda tutar, bu yüzden kendi ayarlar ekranından girilir: Cline'ı açın, dişli simgesine tıklayın ve alanları doldurun.",
|
||||
"connect.err.choose": "bir araç ve bir model seçin",
|
||||
|
||||
"profile.create_key": "Anahtar oluşturun",
|
||||
"profile.key_for": "Ne için kullanılacak?",
|
||||
"profile.key_for_placeholder": "dizüstü, ci, notebook",
|
||||
"profile.spends_against": "Harcama yeri",
|
||||
"profile.create": "Oluştur",
|
||||
"profile.no_project": "Henüz hiçbir projede değilsiniz ve burada kişisel anahtarlar kapalı.\n Sizi bir projeye eklemesi için bir yöneticiye başvurun.",
|
||||
"profile.keys": "Anahtarlar",
|
||||
"profile.col.name": "Ad",
|
||||
"profile.col.key": "Anahtar",
|
||||
"profile.col.spends_against": "Harcama yeri",
|
||||
"profile.col.created": "Oluşturulma",
|
||||
"profile.col.last_used": "Son kullanım",
|
||||
"profile.col.state": "Durum",
|
||||
"profile.left_project": "ayrıldığınız bir proje",
|
||||
"profile.personal": "Kişisel",
|
||||
"profile.state.active": "etkin",
|
||||
"profile.state.revoked": "iptal edildi",
|
||||
"profile.state.expired": "süresi doldu",
|
||||
"profile.revoke": "İptal et",
|
||||
"profile.revoke_note": "İptal, ağ geçidinin önbellek süresi içinde, en geç bir dakikada geçerli olur.\nAnahtarı yenilemek için: yeni anahtarı oluşturun, ona geçin, eski anahtarın\n\"son kullanım\" zamanı değişmez olduğunda eskisini iptal edin.",
|
||||
"profile.no_keys": "Henüz anahtarınız yok.",
|
||||
"profile.where": "Bulunduğunuz yerler",
|
||||
"profile.scope.org": "kuruluş",
|
||||
"profile.scope.team": "ekip",
|
||||
"profile.scope.project": "proje",
|
||||
"profile.no_memberships": "Hiçbir kuruluşun, ekibin ya da projenin üyesi değilsiniz.",
|
||||
|
||||
"selfuse.heading": "Kullanımınız ve sınırlarınız",
|
||||
"selfuse.requests_30d": "istek · 30 gün",
|
||||
"selfuse.tokens_30d": "token · 30 gün",
|
||||
"selfuse.spend_30d": "harcama · 30 gün",
|
||||
"selfuse.tokens_per_day": "Günlük token",
|
||||
"selfuse.chart_unit": "token",
|
||||
"selfuse.place_use": "— 30 günde %s istek, %s token",
|
||||
"selfuse.budget_of": "<b>%[1]s</b> / %[2]s (%[3]s)",
|
||||
"selfuse.pool.user": "Kullandığınız her şeyi kapsayan bütçeniz",
|
||||
"selfuse.pool.project_member": "Bu projedeki bütçeniz",
|
||||
"selfuse.pool.team_member": "Bu ekipteki bütçeniz",
|
||||
"selfuse.pool.org_member": "Bu kuruluştaki bütçeniz",
|
||||
"selfuse.pool.project": "Projenin bütçesi, üyeleri arasında paylaşılır",
|
||||
"selfuse.pool.team": "Ekibin bütçesi, projeleri arasında paylaşılır",
|
||||
"selfuse.pool.org": "Kuruluşun bütçesi, kuruluşun tamamında paylaşılır",
|
||||
"selfuse.period.day": "bugün",
|
||||
"selfuse.period.month": "bu ay",
|
||||
"selfuse.period.none": "toplamda",
|
||||
"selfuse.period.other": "bu %s",
|
||||
"selfuse.col.limit": "Sınır",
|
||||
"selfuse.col.value": "Değer",
|
||||
"selfuse.limit.rpm": "Dakikada istek",
|
||||
"selfuse.limit.tpm": "Dakikada token",
|
||||
"selfuse.limit.budget": "Bütçe",
|
||||
"selfuse.limit.budget_day": "günlük %s",
|
||||
"selfuse.limit.budget_month": "aylık %s",
|
||||
"selfuse.limit.budget_none": "toplam %s",
|
||||
"selfuse.limit.budget_other": "%[2]s başına %[1]s",
|
||||
"selfuse.limit.models": "Modeller",
|
||||
"selfuse.limit.guard": "Koruma kuralları",
|
||||
"selfuse.limit.keep_nothing": "%s — hiçbir şey saklanmaz: sohbet, not ya da dış model yok",
|
||||
"selfuse.no_limits": "Burada sınır yok.",
|
||||
"selfuse.nowhere": "Henüz harcama yapabileceğiniz bir yer yok.",
|
||||
"selfuse.strictest": "Kuruluştan size kadar geçerli olan her şey: en sıkı değer geçerlidir.",
|
||||
|
||||
"profile.password": "Parola",
|
||||
"profile.password_changed": "Parolanız değiştirildi. Oturum açık olan diğer tüm tarayıcılarda\noturumunuz kapatıldı.",
|
||||
"profile.current_password": "Mevcut parola",
|
||||
"profile.new_password": "Yeni parola",
|
||||
"profile.change_password": "Parolayı değiştir",
|
||||
"profile.password_note": "Parolayı değiştirmek diğer tüm tarayıcılardaki oturumları kapatır; bir sorun\nolduğu için değiştiriyorsanız amaç da zaten budur.",
|
||||
"profile.err.personal_off": "bu ağ geçidinde kişisel anahtarlar kapalı; bir proje seçin",
|
||||
"profile.err.try_again": "bir sorun oluştu; yeniden deneyin",
|
||||
"profile.err.not_member": "o projenin üyesi değilsiniz",
|
||||
"profile.err.wrong_password": "mevcut parolanız bu değil",
|
||||
"profile.err.same_password": "bu zaten kullandığınız parola",
|
||||
"connect.own_code": "Kendi kodunuzdan: resmî OpenAI ve Anthropic SDK'ları olduğu gibi çalışır;\nnasıl yapılacağını <a href=\"/docs/clients\">istemci kılavuzu</a> Python, TypeScript, Go ve curl ile gösteriyor."
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"chart.peak_unit": "en yüksek %s %s",
|
||||
"chart.nothing_recorded": "kayıt yok",
|
||||
"chart.hour_label": "%s — %s %s",
|
||||
"chart.nothing_yet": "Henüz bir şey kaydedilmedi.",
|
||||
"chart.trend": "son eğilim",
|
||||
"chart.peak": "en yüksek %s",
|
||||
"chart.other": "diğer",
|
||||
"chart.axis_ms": "%sms",
|
||||
"chart.axis_s": "%ssn",
|
||||
"chart.window.hour": "bir saat",
|
||||
"chart.window.hours.other": "%d saat",
|
||||
"chart.window.minutes.other": "%d dakika"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"chat.title": "Sohbet",
|
||||
"chat.context.using": "Harcanacak yer",
|
||||
"chat.context.switch": "değiştir",
|
||||
"chat.model": "Model",
|
||||
"chat.model.simulated": "(benzetim)",
|
||||
"chat.model.not_loaded": "(yüklü değil)",
|
||||
"chat.new": "Yeni sohbet",
|
||||
"chat.settings": "Ayarlar",
|
||||
"chat.settings.system": "Sistem istemi",
|
||||
"chat.settings.system_hint": "modelin nasıl davranacağı; her turdan önce gönderilir",
|
||||
"chat.settings.system_placeholder": "Kısa ve öz yanıt veren bir asistansın. Soru hangi dilde sorulduysa o dilde yanıtla.",
|
||||
"chat.settings.temperature": "Sıcaklık",
|
||||
"chat.settings.temperature_hint": "düşük değer daha tutarlı, yüksek değer daha çeşitli yanıt verir",
|
||||
"chat.note.simulated": "Bu model bir benzetimdir: zamanlamayı sınar ve süreleri bildirir; yanıtları gerçek çıkarım değil, yer tutucu metindir.",
|
||||
"chat.note.on_demand": "Bu model şu anda çalışmıyor. İlk mesaj onu bir düğüme yükler — ağırlıklarını indirir ve motoru başlatır; büyük bir modelde bu birkaç dakika sürer — ve model hazır olunca gönderilir.",
|
||||
"chat.input_placeholder": "Bir şey sorun…",
|
||||
"chat.send": "Gönder",
|
||||
"chat.kept": "Sohbet bu tarayıcıda saklanır; sayfayı yenilemek onu silmez, \"Yeni sohbet\" temizler. Ağ geçidinde sohbetten hiçbir şey saklanmaz — orada kaydedilen, her isteğin kaydettiği kullanımla aynıdır: token, maliyet ve gecikme.",
|
||||
"chat.empty": "Şu anda kullanabileceğiniz bir model yok.",
|
||||
"chat.empty_why": "Ya henüz hiçbir model yerleştirilmedi ya da projenizin izin listesi yerleştirilmiş olanların hiçbirini içermiyor. Erişiminiz varsa <code>/admin</code> kümeyi gösterir.",
|
||||
|
||||
"chat.js.preparing": "Hazırlanıyor…",
|
||||
"chat.js.download_failed": "indirme başarısız oldu",
|
||||
"chat.js.stage.waiting": "Yeri olan bir düğüm bekleniyor",
|
||||
"chat.js.stage.downloading": "Ağırlıklar indiriliyor",
|
||||
"chat.js.stage.starting": "Motor başlatılıyor",
|
||||
"chat.js.stage.ready": "Hazır",
|
||||
"chat.js.of": "{done} / {total}",
|
||||
"chat.js.could_not_start": "model başlatılamadı",
|
||||
"chat.js.tokens": "{n} token",
|
||||
"chat.js.tok_per_s": "saniyede {n} token",
|
||||
"chat.js.first_token": "ilk token {n} sn",
|
||||
"chat.js.seconds": "{n} sn",
|
||||
"chat.js.stopped": "durduruldu",
|
||||
"chat.js.stop": "Durdur",
|
||||
"chat.js.refused": "Ağ geçidinin politikası{policy} tarafından reddedildi: {message}",
|
||||
"chat.js.download_zip": ".zip olarak indir",
|
||||
"chat.js.stopped_by_policy": "Ağ geçidinin politikası{policy} tarafından durduruldu.",
|
||||
"chat.js.cut_off": "Uzunluk sınırında kesildi — devam etmesini isteyin.",
|
||||
"chat.js.stopped_by_policy_empty": "(hiçbir şey gösterilmeden ağ geçidinin politikası{policy} tarafından durduruldu)",
|
||||
"chat.js.stopped_empty": "(hiçbir yanıt gelmeden durduruldu)",
|
||||
"chat.js.no_content": "(hiçbir içerik gelmedi)",
|
||||
|
||||
"download.err.read": "istek okunamadı",
|
||||
"download.err.no_code": "bu yanıtta indirilecek kod yok"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"claim.title": "llmgw anahtarınız",
|
||||
"claim.heading": "API anahtarınız",
|
||||
"claim.shown_once": "Bu anahtar yalnızca bir kez gösterilir. Yeniden üretilemez; sistemde yalnızca özeti (hash) saklandı.",
|
||||
"claim.store_it": "Anahtarı şimdi bir parola yöneticisine kaydedin, ardından bu sayfayı kapatın.",
|
||||
"claim.using_it": "Nasıl kullanılır",
|
||||
"claim.replace_placeholder": "<model-name> yerine <code>GET %s/v1/models</code> listesindeki bir adı yazın; bu liste için anahtarınız gerekir. Yer tutucuyu değiştirmeden gönderirseniz yanıt olarak kullanılabilir modellerin listesi döner.",
|
||||
"claim.endpoints": "<code>GET %[1]s/v1/models</code> bu anahtarın kullanabileceklerini listeler.\n<code>GET %[1]s/me</code> sınırlarını ve şimdiye kadarki harcamasını gösterir.",
|
||||
"claim.ask_again": "Bağlantı yalnızca bir kez çalışır, sonra geçersiz olur. Size gönderen kişiden yenisini isteyin.",
|
||||
"claim.issued": "Sizin için bir anahtar oluşturuldu. Anahtarı görüntülediğinizde bu bağlantı kullanılmış olur ve anahtar yalnızca bu seferlik gösterilir.",
|
||||
"claim.reveal": "Anahtarımı göster",
|
||||
"claim.expires": "Son geçerlilik: %s.",
|
||||
"claim.err.claimed": "bu bağlantı artık geçerli değil"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"_meta.name": "Türkçe",
|
||||
"_meta.decimal": ",",
|
||||
"_meta.group": ".",
|
||||
"_meta.date": "02.01.2006",
|
||||
"_meta.datetime": "02.01.2006 15:04",
|
||||
|
||||
"lang.switch": "Dil",
|
||||
|
||||
"num.thousands": "%s bin",
|
||||
"num.millions": "%s mn",
|
||||
"num.billions": "%s mr",
|
||||
|
||||
"unit.ms": "%s ms",
|
||||
"unit.s": "%s sn",
|
||||
"unit.min_s": "%d dk %02d sn",
|
||||
"unit.age_s": "%dsn",
|
||||
"unit.age_m": "%ddk",
|
||||
"unit.age_hm": "%dsa%02ddk",
|
||||
"unit.age_d": "%dg",
|
||||
|
||||
"time.never": "hiçbir zaman",
|
||||
"time.just_now": "az önce",
|
||||
"time.minutes_ago.other": "%d dakika önce",
|
||||
"time.hours_ago.other": "%d saat önce",
|
||||
"time.days_ago.other": "%d gün önce",
|
||||
"time.weeks_ago.other": "%d hafta önce",
|
||||
"time.months_ago.other": "%d ay önce",
|
||||
|
||||
"time.weekday.sun": "Pazar",
|
||||
"time.weekday.mon": "Pazartesi",
|
||||
"time.weekday.tue": "Salı",
|
||||
"time.weekday.wed": "Çarşamba",
|
||||
"time.weekday.thu": "Perşembe",
|
||||
"time.weekday.fri": "Cuma",
|
||||
"time.weekday.sat": "Cumartesi",
|
||||
"time.wd.sun": "Paz",
|
||||
"time.wd.mon": "Pzt",
|
||||
"time.wd.tue": "Sal",
|
||||
"time.wd.wed": "Çar",
|
||||
"time.wd.thu": "Per",
|
||||
"time.wd.fri": "Cum",
|
||||
"time.wd.sat": "Cmt",
|
||||
"time.month.jan": "Ocak",
|
||||
"time.month.feb": "Şubat",
|
||||
"time.month.mar": "Mart",
|
||||
"time.month.apr": "Nisan",
|
||||
"time.month.may": "Mayıs",
|
||||
"time.month.jun": "Haziran",
|
||||
"time.month.jul": "Temmuz",
|
||||
"time.month.aug": "Ağustos",
|
||||
"time.month.sep": "Eylül",
|
||||
"time.month.oct": "Ekim",
|
||||
"time.month.nov": "Kasım",
|
||||
"time.month.dec": "Aralık",
|
||||
"time.mon.jan": "Oca",
|
||||
"time.mon.feb": "Şub",
|
||||
"time.mon.mar": "Mar",
|
||||
"time.mon.apr": "Nis",
|
||||
"time.mon.may": "May",
|
||||
"time.mon.jun": "Haz",
|
||||
"time.mon.jul": "Tem",
|
||||
"time.mon.aug": "Ağu",
|
||||
"time.mon.sep": "Eyl",
|
||||
"time.mon.oct": "Eki",
|
||||
"time.mon.nov": "Kas",
|
||||
"time.mon.dec": "Ara",
|
||||
|
||||
"engine.build.auto": "otomatik: donanımın çalıştırdığı",
|
||||
"engine.build.none": "yok",
|
||||
|
||||
"common.copy": "kopyala",
|
||||
"common.copied": "kopyalandı",
|
||||
"common.download": "indir",
|
||||
"common.save": "Kaydet",
|
||||
"common.cancel": "Vazgeç",
|
||||
"common.remove": "Kaldır",
|
||||
"common.delete": "Sil",
|
||||
"common.add": "Ekle",
|
||||
"common.yes": "evet",
|
||||
"common.no": "hayır",
|
||||
"common.none": "yok",
|
||||
"common.about_gateway": "bu ağ geçidi hakkında",
|
||||
"common.refreshes_every": "%d saniyede bir yenilenir",
|
||||
"common.template_error": "Sayfa oluşturulamadı."
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"dash.title": "küme",
|
||||
"dash.h1": "Küme",
|
||||
"dash.sub": "%d düğüm · %d/%d replika hazır · %d model",
|
||||
"dash.nav.metrics": "metrikler",
|
||||
|
||||
"dash.tile.requests": "istek · 24 sa",
|
||||
"dash.tile.tokens": "token · 24 sa",
|
||||
"dash.tile.errors": "hata · 24 sa",
|
||||
"dash.tile.spend": "harcama · 24 sa",
|
||||
|
||||
"dash.capacity": "Kapasite",
|
||||
"dash.cap.class": "Sınıf",
|
||||
"dash.cap.in_use": "Kullanımda",
|
||||
"dash.cap.free": "Boş",
|
||||
"dash.cap.faulty": "Arızalı",
|
||||
"dash.cap.usage": "Kullanım",
|
||||
"dash.cpu.ready": "düğüm hazır · yalnızca CPU",
|
||||
"dash.cpu.memory": "bellek",
|
||||
"dash.cpu.disk_of": "%s / %s GB",
|
||||
"dash.cpu.disk_free": "ağırlıklar için boş",
|
||||
"dash.no_nodes": "Henüz hiçbir düğüm kaydolmadı.",
|
||||
|
||||
"dash.gpumap.title": "GPU'lar",
|
||||
"dash.gpumap.by": "renklendirme:",
|
||||
"dash.gpumap.mode.util": "meşguliyet",
|
||||
"dash.gpumap.mode.memory": "bellek",
|
||||
"dash.gpumap.mode.temp": "sıcaklık",
|
||||
"dash.gpumap.legend.temp": "Mavi serin, kırmızı 90 °C ve üstü.",
|
||||
"dash.gpumap.legend.memory": "Açık renk boş, koyu renk dolu. Motorlar başlarken bir kartın belleğinin çoğunu ayırır.",
|
||||
"dash.gpumap.legend.util": "Açık renk boşta, koyu yeşil tamamen meşgul. Kesikli kenarlık, hiçbir replikanın tutmadığı bir karttır.",
|
||||
"dash.gpumap.free": "boş",
|
||||
"dash.gpumap.busy": "%s%% meşgul",
|
||||
"dash.gpumap.memory": "%s / %s GB",
|
||||
"dash.gpumap.held_back": "kısıtlayan: %s",
|
||||
"dash.gpumap.note": "Kırmızı çerçeve, yıpranmakta olan bir karttır: XID hataları, yeniden eşlenmiş ya da sıfırlama bekleyen bellek satırları, NVLink hataları. Karttaki rakam, renklendirmede kullanılan değerdir; kartın geri kalanı imlecin altındadır.",
|
||||
|
||||
"dash.eff.title": "Her modelin kartlarından aldığı verim",
|
||||
"dash.eff.window": "son 15 dakika",
|
||||
"dash.col.model": "Model",
|
||||
"dash.eff.tps": "Çıkış token/sn",
|
||||
"dash.eff.per_gpu": "GPU başına",
|
||||
"dash.eff.busy": "Meşgul",
|
||||
"dash.eff.draw": "Çekilen güç",
|
||||
"dash.eff.per_wh": "Wh başına token",
|
||||
|
||||
"dash.idle.title": "Tutulan ama hiçbir şey yapmayan kartlar",
|
||||
"dash.col.replica": "Replika",
|
||||
"dash.col.node": "Düğüm",
|
||||
"dash.idle.busiest": "En meşgul kart",
|
||||
"dash.idle.last_request": "Son istek",
|
||||
"dash.idle.ago": "%s önce",
|
||||
"dash.idle.unknown": "bilinmiyor",
|
||||
"dash.clock.h": "sa",
|
||||
"dash.clock.m": "dk",
|
||||
"dash.clock.s": "sn",
|
||||
"dash.clock.ms": "ms",
|
||||
|
||||
"dash.nodes": "Düğümler",
|
||||
"dash.col.state": "Durum",
|
||||
"dash.nodes.ram": "RAM",
|
||||
"dash.nodes.disk": "Disk",
|
||||
"dash.nodes.engines": "Motorlar",
|
||||
"dash.nodes.build": "vLLM derlemesi",
|
||||
"dash.nodes.classes": "Sınıflar",
|
||||
"dash.nodes.fabrics": "Fabric'ler",
|
||||
"dash.nodes.models": "Modeller",
|
||||
"dash.nodes.replicas": "Replikalar",
|
||||
"dash.nodes.seen": "Görülme",
|
||||
"dash.nodes.logs": "günlükler",
|
||||
"dash.nodes.cpus_of": "· %d çekirdek",
|
||||
"dash.nodes.mem_of": "/ %s",
|
||||
"dash.nodes.no_engines": "henüz yok",
|
||||
"dash.nodes.build_hint": "bu düğümün kurduğu vLLM derlemesi",
|
||||
"dash.no_agents": "Henüz hiçbir ajan kaydolmadı.",
|
||||
|
||||
"dash.state.ready": "hazır",
|
||||
"dash.state.cordoned": "kordon altında",
|
||||
"dash.state.draining": "boşaltılıyor",
|
||||
"dash.state.unreachable": "erişilemiyor",
|
||||
"dash.state.lost": "kayıp",
|
||||
"dash.state.unknown": "bilinmiyor",
|
||||
"dash.state.placing": "yerleştiriliyor",
|
||||
"dash.state.pending": "bekliyor",
|
||||
"dash.state.starting": "başlatılıyor",
|
||||
"dash.state.loading": "yükleniyor",
|
||||
"dash.state.stopped": "durduruldu",
|
||||
"dash.state.failed": "başarısız",
|
||||
"dash.state.installing": "kuruluyor",
|
||||
"dash.state.unsupported": "desteklenmiyor",
|
||||
"dash.state.skipped": "atlandı",
|
||||
|
||||
"dash.models": "Modeller",
|
||||
"dash.models.aliases": "Takma adlar",
|
||||
"dash.models.ready": "Hazır",
|
||||
"dash.models.wanted": "İstenen",
|
||||
"dash.models.class": "Sınıf",
|
||||
"dash.models.reserves": "Ayırdığı",
|
||||
"dash.models.profiles": "Profiller",
|
||||
"dash.models.fallbacks": "Yedekler",
|
||||
"dash.models.for": "Ne için",
|
||||
"dash.models.held_for": "%s için yer açtı",
|
||||
"dash.models.preempted": "daha yüksek bir sınıfa kaptırılan replikalar",
|
||||
"dash.models.used": "%sG kullanılıyor",
|
||||
"dash.models.external_hint": "kümenin dışında sunuluyor; istemleri kümeden çıkar",
|
||||
"dash.models.external": "harici",
|
||||
"dash.models.empty": "Katalog boş.",
|
||||
"dash.unplaced": "Neden hiçbir şey yerleştirilmedi",
|
||||
|
||||
"dash.replicas": "Replikalar",
|
||||
"dash.replicas.pool": "Havuz",
|
||||
"dash.replicas.endpoint": "Uç nokta",
|
||||
"dash.replicas.age": "Yaş",
|
||||
"dash.replicas.none": "Replika yok.",
|
||||
"dash.holding": "Kimsenin istemediği donanımı tutanlar",
|
||||
"dash.holding.unused_for": "Kullanılmadığı süre",
|
||||
|
||||
"dash.fabrics.fabric": "Fabric",
|
||||
"dash.fabrics.physically": "Fiziksel olarak",
|
||||
"dash.fabrics.fully_free": "Tamamen boş",
|
||||
"dash.fabrics.weights": "Ağırlıklar",
|
||||
"dash.fabrics.weights_on": "%[2]d/%[3]d düğümde %[1]d model",
|
||||
"dash.fabrics.not_reported": "bildirilmedi",
|
||||
"dash.fabrics.see_map": "Her fabric'in haritasını görün →",
|
||||
"dash.fabrics.sms": "Alt ağ yöneticileri",
|
||||
"dash.fabrics.master": "Ana yönetici",
|
||||
"dash.fabrics.standby": "Yedek",
|
||||
"dash.fabrics.last_change": "Ana yöneticinin son değişimi",
|
||||
"dash.fabrics.roce": "yok: RoCE",
|
||||
"dash.fabrics.lid_priority": "LID %d, öncelik %d",
|
||||
"dash.fabrics.none": "yok",
|
||||
"dash.fabrics.not_listed": "listelenmedi",
|
||||
"dash.fabrics.in_a_day": "(bir günde %d)",
|
||||
"dash.fabrics.none_in_a_day": "bir gündür yok",
|
||||
"dash.fabrics.note": "Bir fabric'in adı, düğümlere söylenen addır; fiziksel olarak ne olduğu ise InfiniBand portlarının gördüğü alt ağ yöneticisi ya da RoCE portlarının bağlı olduğu ağdır. Birden çok düğüme yayılan bir replika yalnızca bunlardan birinin üzerinde kurulur. Tamamen boş, hiç kullanılmayan bütün düğümleri sayar; böyle bir replikanın yerleşip yerleşemeyeceğini belirleyen de budur.",
|
||||
|
||||
"dash.nvlink.title": "NVLink alanları",
|
||||
"dash.nvlink.domains.other": "%d çok düğümlü alan",
|
||||
"dash.nvlink.trays": "%d/%d tepsi kullanılabilir",
|
||||
"dash.nvlink.problems.other": "%d sorun",
|
||||
"dash.nvlink.see_racks": "rafları görün →",
|
||||
|
||||
"dash.fleet.title": "Küme ne yapıyor",
|
||||
"dash.fleet.last": "son %s",
|
||||
"dash.no_history": "Geçmiş yok: %s",
|
||||
"dash.fleet.saturation": "Havuz doygunluğu, modele göre",
|
||||
"dash.fleet.saturation_note": "Kuyruk derinliği ile önbellek baskısından kötü olanı. 1, sağlıklı olarak ölçülen sınırdır; çökme noktası değildir.",
|
||||
"dash.fleet.ready": "Hazır replikalar, modele göre",
|
||||
"dash.fleet.ready_note": "Bilerek durdurulmuş bir modelin hiç çizgisi olmaz; isteğe bağlı çalışan bir katalogun burada sessiz görünmesinin nedeni budur.",
|
||||
"dash.fleet.gpu_busy": "Meşgul GPU'lar, sınıfa göre",
|
||||
"dash.fleet.gpu_busy_note": "Her sınıfın ortalama kartı. Replikalar yerleştirilmişken burada düşük olması, tutulan ama istenmeyen kapasite demektir; her düğümün sayfası kartlarını tek tek gösterir.",
|
||||
"dash.fleet.gpu_memory": "Kullanılan GPU belleği, sınıfa göre",
|
||||
"dash.fleet.gpu_memory_note": "Motorlar başlarken bir kartın belleğinin çoğunu ayırır; bu yüzden bu grafik sunulan isteklerden çok yerleştirilen replikaları izler.",
|
||||
"dash.fleet.queue": "Motor kuyruk derinliği, replikaya göre",
|
||||
"dash.fleet.queue_note": "Kabul edilmiş ama henüz başlamamış istekler. Bir replika geride kalmaya başladığında ilk kıpırdayan budur.",
|
||||
"dash.fleet.kv": "KV önbellek doluluğu, replikaya göre",
|
||||
"dash.fleet.kv_note": "Dikkat önbelleğinin ne kadar dolu olduğu. Uzun bağlamlar kuyruk boşken de onu doldurur; doygunluğun ikisine birden bakmasının nedeni budur.",
|
||||
|
||||
"dash.traffic": "Trafik",
|
||||
"dash.traffic.window": "— son 24 saat, saatlik",
|
||||
"dash.traffic.requests": "İstekler",
|
||||
"dash.traffic.tokens": "Token'lar",
|
||||
"dash.traffic.latency": "Ortalama gecikme",
|
||||
"dash.traffic.none": "Henüz trafik kaydedilmedi.",
|
||||
"dash.unit.requests": "istek",
|
||||
"dash.unit.tokens": "token",
|
||||
"dash.unit.ms_mean": "ms ortalama",
|
||||
"dash.unit.requests_h": "istek/sa",
|
||||
"dash.unit.tokens_h": "token/sa",
|
||||
|
||||
"dash.people": "Kişiler",
|
||||
"dash.col.person": "Kişi",
|
||||
"dash.people.keys": "Anahtarlar",
|
||||
"dash.col.requests": "İstekler",
|
||||
"dash.col.tokens": "Token'lar",
|
||||
"dash.col.errors": "Hatalar",
|
||||
"dash.col.spend": "Harcama",
|
||||
"dash.people.last_seen": "Son görülme",
|
||||
"dash.people.operator_keys": "operatörün verdiği anahtarlar",
|
||||
|
||||
"dash.running": "Ne çalıştırılıyor",
|
||||
"dash.running.note": "Gerçekten yanıt veren modele göre sayılır; bir yedek model yanıt verdiğinde bu, istenen modelden farklıdır.",
|
||||
|
||||
"dash.usage": "Kullanım",
|
||||
"dash.usage.window": "— son 24 saat",
|
||||
"dash.usage.through": "Üzerinden",
|
||||
"dash.usage.against": "Harcandığı yer",
|
||||
"dash.usage.prompt": "İstem",
|
||||
"dash.usage.completion": "Yanıt",
|
||||
"dash.usage.last": "Son",
|
||||
"dash.usage.chat_page": "sohbet sayfası",
|
||||
"dash.usage.personal": "kişisel",
|
||||
"dash.usage.note": "Harcama birim cinsindendir; bir birimin ne anlama geldiği yapılandırmaya bağlıdır. Kullanım toplu olarak yazılır, bu yüzden son birkaç saniye eksik olabilir.",
|
||||
"dash.usage.none": "Son 24 saatte hiç istek kaydedilmedi.",
|
||||
"dash.usage.auth_off": "Kimlik doğrulama kapalı; bu yüzden kullanımın yazılacağı bir kimlik yok.",
|
||||
|
||||
"dash.admin_listener": "Metrikler, pprof ve yönetim API'si yönetici dinleyicisindedir; bu dinleyici loopback'e bağlıdır ve buradan erişilemez. pprof, API anahtarlarının düz metin olarak durduğu süreç belleğinin dökümünü alabilir; o portun dışarı açılmamasının nedeni budur:"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"docs.title": "llmgw belgeleri",
|
||||
"docs.description": "llmgw'nin kullanımı ve işletimi: istemci kılavuzu, kurulum, işletim ve yapılandırma başvurusu.",
|
||||
"docs.this_gateway": "Bu ağ geçidi",
|
||||
"docs.about": "llmgw hakkında",
|
||||
"docs.documents": "Belgeler",
|
||||
"docs.sub": "Bu ağ geçidinin yazılımıyla birlikte gelen kılavuz: <code>llmgw docs</code> komutunun yazdırdığı\nve konsoldaki Kılavuz ekranının gösterdiği sayfaların aynısı.",
|
||||
"docs.from_code": "Ağ geçidini koddan kullanmak",
|
||||
"docs.from_code_text": "Resmî OpenAI ve Anthropic SDK'ları olduğu gibi çalışır: yalnızca temel URL ile anahtar değişir.\n <a href=\"/docs/%s\">%s</a>; sohbet, akış, araçlar, gömmeler, Responses API'si, toplu işler ve\n llmgw'nin eklediklerini (saklanan sohbetler, etiketler, oturumlar ve bellek notları)\n Python, TypeScript, Go ve curl örnekleriyle gösterir.",
|
||||
"docs.every": "Tüm belgeler",
|
||||
"docs.general": "Belgeler bu kurulumu değil, genel olarak llmgw'yi anlatır:\nbu kuruluma ait hiçbir adres, model ya da anahtar içermez.",
|
||||
"docs.english_only": "Belgelerin kendisi yalnızca İngilizcedir.",
|
||||
"docs.footer": "belgeler"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"fabric.title": "Fabric'ler",
|
||||
"fabric.sub.other": "Bağdaştırıcıların bulduğu biçimiyle %d fiziksel fabric",
|
||||
"fabric.nav.cluster": "küme",
|
||||
"fabric.empty": "Henüz hiçbir InfiniBand ya da RoCE portu bildirimde bulunmadı. Bir düğümün bağdaştırıcıları ilk sinyaliyle burada görünür, fabric'inin haritası da bundan sonraki beş dakika içinde.",
|
||||
"fabric.pills": "Fabric",
|
||||
"fabric.undeclared": "tanımlanmamış",
|
||||
"fabric.master": "ana yönetici",
|
||||
"fabric.lid": "LID %d",
|
||||
"fabric.sms.other": "%d alt ağ yöneticisi",
|
||||
"fabric.mapped": "%[1]s %[2]s üzerinden %[3]s ms'de haritalandı",
|
||||
"fabric.level.fail": "hata",
|
||||
"fabric.level.warn": "uyarı",
|
||||
"fabric.level.info": "bilgi",
|
||||
"fabric.gbps": "%d Gb/s",
|
||||
"fabric.mode.speed": "Hız",
|
||||
"fabric.mode.rails": "Ray",
|
||||
"fabric.traffic": "Trafik",
|
||||
"fabric.only_problems": "Yalnızca sorunlar",
|
||||
"fabric.find": "Düğüm bul",
|
||||
"fabric.reset_hint": "Ya da haritaya çift tıklayın",
|
||||
"fabric.reset": "Görünümü sıfırla",
|
||||
"fabric.noscript": "Harita tarayıcıda çizilir; yukarıdaki sorunlar onsuz da aynıdır.",
|
||||
"fabric.legend.slower": "daha yavaş",
|
||||
"fabric.legend.below": "türünün altında",
|
||||
"fabric.legend.ours": "bu kümenin bağdaştırıcısı",
|
||||
"fabric.legend.other": "başka bir makine",
|
||||
"fabric.legend.down": "port kapalı",
|
||||
"fabric.legend.how": "yakınlaştırmak için kaydırın · taşımak için sürükleyin · ayrıntı için tıklayın",
|
||||
|
||||
"fabric.js.map_failed": "Harita çıkarılamadı: %s",
|
||||
"fabric.js.not_mapped": "Henüz haritalanmadı: ilk tarama, bu fabric'teki bir düğüm bağlandıktan sonraki beş dakika içinde yapılır.",
|
||||
"fabric.js.tier.leaf": "yaprak",
|
||||
"fabric.js.tier.spine": "omurga",
|
||||
"fabric.js.tier.core": "çekirdek",
|
||||
"fabric.js.tier.supercore": "üst çekirdek",
|
||||
"fabric.js.tier.n": "%s. katman",
|
||||
"fabric.js.adapters_row": "bağdaştırıcılar",
|
||||
"fabric.js.ports_used": "%s/%s port",
|
||||
"fabric.js.hosts": "%s makine",
|
||||
"fabric.js.cables_n": "%s kablo, ",
|
||||
"fabric.js.below_rate": "türünün geri kalanının çalıştığı %s Gb/s hızın altında anlaştı",
|
||||
"fabric.js.adapters_n": "%s bağdaştırıcı",
|
||||
"fabric.js.degraded_n": "%s bozulmuş kablo",
|
||||
"fabric.js.rail": "ray %s",
|
||||
"fabric.js.not_ours": "bu kümenin düğümlerinden biri değil",
|
||||
"fabric.js.dt.tier": "katman",
|
||||
"fabric.js.dt.ports": "portlar",
|
||||
"fabric.js.dt.adapters": "bağdaştırıcılar",
|
||||
"fabric.js.dt.adapter": "bağdaştırıcı",
|
||||
"fabric.js.dt.rail": "ray",
|
||||
"fabric.js.dt.leaf": "yaprak",
|
||||
"fabric.js.dt.state": "durum",
|
||||
"fabric.js.dt.rate": "hız",
|
||||
"fabric.js.dt.sending": "gönderilen",
|
||||
"fabric.js.dt.receiving": "alınan",
|
||||
"fabric.js.cabled": "%s/%s port kablolu",
|
||||
"fabric.js.degraded_cables": "Bozulmuş kablolar",
|
||||
"fabric.js.all_good": "Her kablo beklenen hızında.",
|
||||
"fabric.js.port_to": "port %s → %s: %s",
|
||||
"fabric.js.close": "kapat",
|
||||
"fabric.js.open_node": "%s düğümünü aç →",
|
||||
"fabric.js.adapter": "bağdaştırıcı",
|
||||
"fabric.js.stat.switches": "anahtarlar",
|
||||
"fabric.js.stat.adapters": "bağdaştırıcılar",
|
||||
"fabric.js.stat.cables": "kablolar",
|
||||
"fabric.js.stat.degraded": "bozulmuş",
|
||||
"fabric.js.stat.rails": "raylar",
|
||||
"fabric.js.stat.aligned": "hizalı",
|
||||
"fabric.js.stat.crossed": "çapraz",
|
||||
"fabric.js.stat.sent": "şu an gönderilen",
|
||||
|
||||
"nvlink.title": "NVLink alanları",
|
||||
"nvlink.sub.other": "%d çok düğümlü alan — GPU'ları düğümler arasında NVLink ile birbirine ulaşan raflar",
|
||||
"nvlink.empty": "Hiçbir GPU çok düğümlü bir NVLink alanı bildirmiyor. Sekiz GPU'lu bir makinenin NVSwitch'i kasasında biter ve düğümünün sayfasında gösterilir; fabric yöneticisi bir NVL72 rafının ya da bir NVLink Switch sisteminin GPU'larını bir alana kattığında alan burada görünür.",
|
||||
"nvlink.clique": "klik %v",
|
||||
"nvlink.trays_usable": "tepsi kullanılabilir",
|
||||
"nvlink.gpus_joined": "GPU katıldı",
|
||||
"nvlink.no_fabric": "fabric yok",
|
||||
"nvlink.imex": "nvidia-imex %s, %d kanal",
|
||||
"nvlink.note": "Düğümlere yayılan bir replika önce bunlardan birinin içine yerleştirilir: düğümleri arasındaki tensör paralelliği o zaman InfiniBand yerine NVLink üzerinden çalışır. Yalnızca bütün GPU'ları katılmış ve IMEX'i çalışan tepsiler kullanılır; ikisinden biri eksik bir tepsi, replikayı bir kart ya da o kartın belleği kadar eksik bırakırdı."
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"guard.title": "Koruma kuralları",
|
||||
"guard.sub": "Politikaların ne yaptığı, nerede ve hangi kuralla — yalnızca adlar ve sayılar, yazılanlar asla.",
|
||||
"guard.policies": "Politikalar",
|
||||
"guard.defined_in": "<code>server.yaml</code> içinde tanımlı:",
|
||||
"guard.applied": "Her isteğe uygulanan:",
|
||||
"guard.col.scope": "Kapsam",
|
||||
"guard.col.who": "Kim",
|
||||
"guard.col.policy": "Politika",
|
||||
"guard.scope.org": "kuruluş",
|
||||
"guard.scope.team": "ekip",
|
||||
"guard.scope.project": "proje",
|
||||
"guard.scope.user": "kişi",
|
||||
"guard.scope.key": "anahtar",
|
||||
"guard.attached_nowhere": "Başka hiçbir yere bağlı değil. Bağlamak için: <code>llmgw ctl guard attach project:ORG/TEAM/PROJECT POLICY</code>",
|
||||
"guard.no_policies": "Politika yok: <code>server.yaml</code> dosyasına bir <code>guard:</code> bölümü ekleyin (docs/guardrails.md).",
|
||||
|
||||
"guard.what_they_did": "Ne yaptılar",
|
||||
"guard.over_last": "Son",
|
||||
"guard.col.where": "Nerede",
|
||||
"guard.col.action": "Eylem",
|
||||
"guard.col.category": "Kategori",
|
||||
"guard.col.requests": "İstek",
|
||||
"guard.personal": "kişisel",
|
||||
"guard.action.flagged": "işaretlendi",
|
||||
"guard.action.redacted": "maskelendi",
|
||||
"guard.action.blocked": "engellendi",
|
||||
"guard.nothing": "İşaretlenen, maskelenen ya da engellenen bir şey yok.",
|
||||
|
||||
"guard.recent": "Son olaylar",
|
||||
"guard.col.when": "Zaman (UTC)",
|
||||
"guard.col.request": "İstek",
|
||||
"guard.col.model": "Model",
|
||||
"guard.col.status": "Durum",
|
||||
"guard.request_hint": "Bir isteğin başka nelerden geçtiğini <code>llmgw ctl request ID</code> söyler."
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"landing.description": "Kendi sunucularınızda çalışan modellerin önünde, OpenAI uyumlu tek bir uç nokta.",
|
||||
"landing.open_warning": "Bu ağ geçidinde hiç API anahtarı tanımlanmamış; bu yüzden erişebilen herkese hizmet veriyor.\nBu, kurulumu yapanın bilinçli bir tercihidir ve yalnızca güvenilir bir ağda güvenlidir.",
|
||||
"landing.sub": "Kendi GPU'larınızın önünde, OpenAI uyumlu tek bir uç nokta.",
|
||||
"landing.lede": "Herhangi bir OpenAI istemcisini bu adrese yönlendirin. API, zaten istemcisi\n elinizde olan API'nin ta kendisi; değişen tek şey temel URL.",
|
||||
"landing.create_account": "Hesap oluştur",
|
||||
"landing.sign_in": "Giriş yap",
|
||||
"landing.schematic": "İstemciler tek bir uç noktaya ulaşır; bu uç nokta işleri ayrı fabric'lerdeki GPU düğümlerine dağıtır.",
|
||||
"landing.schematic.client": "istemciniz",
|
||||
"landing.schematic.editor": "editör ajanı",
|
||||
"landing.schematic.script": "bir betik",
|
||||
"landing.schematic.address": "tek adres",
|
||||
"landing.schematic.fabric": "fabric",
|
||||
"landing.schematic_caption": "İstemciler tek bir adres görür. İşler, onları gerçekten çalıştırabilecek GPU'lara\n yerleştirilir; birden çok makineye bölünen bir model hiçbir zaman iki fabric'e yayılmaz.",
|
||||
"landing.base_url": "Temel URL",
|
||||
"landing.three_steps": "Üç adımda başlayın",
|
||||
"landing.step1": "Anahtar alın",
|
||||
"landing.step1_signup": "<a href=\"/signup\">Bir hesap oluşturun</a>. Hesabınız onaylandıktan sonra\n <a href=\"/profile\">profilinizden</a> kendi anahtarlarınızı oluşturursunuz: ihtiyacınız kadar;\n bir dizüstü bilgisayarı kullanımdan kaldırdığınızda ya da bir betiğin işi bittiğinde onları kendiniz iptal edersiniz.",
|
||||
"landing.step1_contact": "Anahtar için şuraya başvurun: %s.",
|
||||
"landing.step1_contact_signin": " Zaten bir hesabınız varsa\n <a href=\"/login\">giriş yapın</a>.",
|
||||
"landing.step1_nobody": "Anahtarları bu ağ geçidini işleten kişi ya da ekip verir. Kim olduğunu\n bilmiyorsanız, bu sayfaya ulaştığınız adres sormaya başlamak için doğru yerdir.",
|
||||
"landing.step1_shown_once": "Anahtar yalnızca bir kez gösterilir ve bir daha asla gösterilmez; burada yalnızca\n özeti (hash) saklanır. Bu yüzden anahtarı hemen bir parola yöneticisine kaydedin.",
|
||||
"landing.step2": "İstemcinizi buraya yönlendirin",
|
||||
"landing.step2_text": "Yukarıdaki temel URL'yi ve anahtarınızı ayarlayın. Her OpenAI SDK'sı ve onun üzerine\n kurulu her araç, ikisini de yapılandırma olarak alır.",
|
||||
"landing.step3": "Bir model seçin",
|
||||
"landing.step3_keyed": "<code>GET /v1/models</code>, anahtarınızın kullanabileceği modelleri listeler. Bu istek\n anahtar gerektirir; modellerin bu sayfada listelenmemesinin nedeni de budur: bu sayfa\n adrese ulaşabilen herkese açıktır.",
|
||||
"landing.step3_open": "<code>GET /v1/models</code> kullanılabilir modelleri listeler.",
|
||||
"landing.step3_placeholder": "Yer tutucu <code><model-name></code> değiştirilmeden gönderilirse\n hata yerine kullanabileceğiniz modellerin listesi döner.",
|
||||
"landing.connect": "İstemcinizi bağlayın",
|
||||
"landing.anything_works": "Chat completions API'sini konuşan her şey çalışır, burada listelenmiş olsun ya da olmasın;\nllmgw'ye özel kurulacak hiçbir şey yoktur.",
|
||||
"landing.once_you_have_key": "Anahtarınızı aldıktan sonra",
|
||||
"landing.models_endpoint": "anahtarınızın kullanabildikleri",
|
||||
"landing.me_endpoint": "sınırları ve şimdiye kadarki harcaması",
|
||||
"landing.client_guide": "Daha fazlası <a href=\"/docs/clients\">istemci kılavuzunda</a>: akış, araçlar, gömmeler (embeddings) ve Anthropic SDK."
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
{
|
||||
"mirror.title": "Ayna",
|
||||
"mirror.heading": "Model aynası",
|
||||
"mirror.did_not_answer": "yanıt vermedi",
|
||||
"mirror.tabs_aria": "Ayna",
|
||||
"mirror.tab.held": "Aynadakiler",
|
||||
"mirror.tab.unreachable": "erişilemiyor",
|
||||
"mirror.tab.downloading": "%s · %s iniyor",
|
||||
"mirror.tab.failed": "%s başarısız",
|
||||
"mirror.tab.explore": "Hugging Face'i keşfet",
|
||||
"mirror.tab.fetch": "URL ile indir",
|
||||
"mirror.unreachable": "Aynadan hiçbir şey okunamadı; bu yüzden bu sayfa aynada ne olduğunu göstermiyor — ayna şu anda <code>%s</code> adresinde erişilebilir değil. <code>llmgw-mirror</code> hizmetinin orada çalıştığını ve yönetim bağlantı noktasına bu makineden erişilebildiğini denetleyin.",
|
||||
|
||||
"mirror.col.model": "Model",
|
||||
"mirror.col.files": "Dosyalar",
|
||||
"mirror.col.size": "Boyut",
|
||||
"mirror.col.runs": "Çalışma",
|
||||
"mirror.group.models.other": "%d model · %s",
|
||||
"mirror.state.fetching": "iniyor",
|
||||
"mirror.state.failed": "başarısız",
|
||||
"mirror.group.in_use": "kullanımda",
|
||||
"mirror.adapter_title": "%s modelleri için bir LoRA adaptörü: bir modelin kartından ekleyin",
|
||||
"mirror.adapter": "LoRA adaptörü",
|
||||
"mirror.file_sha": "%s · sha256 %s",
|
||||
"mirror.files.other": "%d dosya",
|
||||
"mirror.percent_of": "%[2]s içinden %%%[1]d",
|
||||
"mirror.so_far": "şu ana dek %s",
|
||||
"mirror.use.as": "%s adıyla",
|
||||
"mirror.use.yaml_title": "server.yaml içinde tanımlı; bu dosya her başlangıçta yeniden okunur",
|
||||
"mirror.use.yaml": "%s · yaml",
|
||||
"mirror.mode.always": "sürekli açık",
|
||||
"mirror.mode.ondemand": "istek üzerine",
|
||||
"mirror.mode.stopped": "durduruldu",
|
||||
"mirror.use.mode_aria": "%s nasıl çalışır",
|
||||
"mirror.use.set": "<button type=\"submit\" class=\"link\">uygula</button>",
|
||||
"mirror.use.runs_title": "hazır / istenen replika",
|
||||
"mirror.use.runs_title_starting": "hazır / istenen replika, + başlayan",
|
||||
"mirror.use.parked": "bekletiliyor",
|
||||
"mirror.use.none_running": "çalışan yok",
|
||||
"mirror.use.no_room": "yer yok",
|
||||
"mirror.not_served": "sunulmuyor",
|
||||
"mirror.retry": "yeniden dene",
|
||||
"mirror.copy_address": "adresi kopyala",
|
||||
"mirror.close": "kapat",
|
||||
"mirror.serve": "sun",
|
||||
"mirror.remove_confirm": "%s/%s ve içindeki tüm dosyalar kaldırılsın mı?",
|
||||
"mirror.remove_confirm_in_use": "%s/%s ve içindeki tüm dosyalar kaldırılsın mı? Katalog bunu kullanıyor.",
|
||||
"mirror.remove": "kaldır",
|
||||
"mirror.copy_note": "“adresi kopyala”, bir katalog girdisinin <code>source</code> alanına yazılması gerekeni verir: dosyayı ya da birden çok dosyalı bir sürüm için sonunda eğik çizgiyle dizini. Elle birleştirilmek yerine sayfada hazır verilir, çünkü ayna ile kataloğun bir dosyayı aynı biçimde yazması gerekir; bu sayfa hangi modellerin onu kullandığını da böyle anlar.",
|
||||
"mirror.address_prompt": "Adres",
|
||||
"mirror.empty": "Aynaya erişiliyor, ancak henüz hiçbir şey içermiyor. <a href=\"/admin/mirror?tab=fetch\">URL ile bir model indirin</a>.",
|
||||
"mirror.empty_explore": "Aynaya erişiliyor, ancak henüz hiçbir şey içermiyor. Bir model için <a href=\"/admin/mirror?tab=explore\">Hugging Face'i keşfedin</a> ya da <a href=\"/admin/mirror?tab=fetch\">URL ile bir model indirin</a>.",
|
||||
|
||||
"explore.views_aria": "Hangi modeller",
|
||||
"explore.view.trending": "Gündemde",
|
||||
"explore.view.new": "Bu ay yeni",
|
||||
"explore.view.downloads": "En çok indirilen",
|
||||
"explore.view.adapters": "LoRA adaptörleri",
|
||||
"explore.adapters_for": "<code>%s</code> için yapılmış LoRA adaptörleri ·",
|
||||
"explore.all_adapters": "tüm adaptörler",
|
||||
"explore.adapters_any": "Herhangi bir model için LoRA adaptörleri. Aynadaki bir modelin kartında <em>adaptör bul</em>, o model için yapılmış olanları listeler.",
|
||||
"explore.adapters_note": "Bir adaptör yalnızca yapıldığı model üzerinde ve yalnızca GGUF adaptör dosyası olarak çalışır; hangi dosyalarının öyle olduğunu görmek için birini açın.",
|
||||
"explore.task_aria": "Görev",
|
||||
"explore.task.chat": "Sohbet",
|
||||
"explore.task.embed": "Gömme",
|
||||
"explore.task.any": "Her görev",
|
||||
"explore.size_aria": "Boyut",
|
||||
"explore.size.any": "Her boyut",
|
||||
"explore.search_placeholder": "Hugging Face'te ara — qwen coder, gemma, bge…",
|
||||
"explore.search_aria": "Hugging Face'te ara",
|
||||
"explore.only_here": "yalnızca bu düğümlerin çalıştırabildikleri",
|
||||
"explore.search": "Ara",
|
||||
"explore.registry_err": "Model deposu: %s",
|
||||
"explore.col.repo": "Depo",
|
||||
"explore.col.size": "Boyut",
|
||||
"explore.col.arch": "Mimari",
|
||||
"explore.col.context": "Bağlam",
|
||||
"explore.col.downloads": "İndirme",
|
||||
"explore.col.likes": "Beğeni",
|
||||
"explore.col.updated": "Güncellendi",
|
||||
"explore.new": "yeni",
|
||||
"explore.gated": "erişimi kısıtlı",
|
||||
"explore.from": "temeli: %s",
|
||||
"explore.age.now": "az önce",
|
||||
"explore.age.h": "%d sa",
|
||||
"explore.age.d": "%d g",
|
||||
"explore.age.w": "%d hf",
|
||||
"explore.age.mo": "%d ay",
|
||||
"explore.js.loading": "Yükleniyor…",
|
||||
"explore.js.load_failed": "Yüklenemedi:",
|
||||
"explore.js.starting": "Başlatılıyor…",
|
||||
"explore.js.refused": "reddedildi",
|
||||
"explore.js.follow": "Aynadakiler sekmesinden izleyin",
|
||||
"explore.js.started": "Başladı",
|
||||
"explore.js.unreachable": "Ağ geçidine ulaşılamadı:",
|
||||
"explore.hidden.other": "%d sonuç daha gizlendi: mimarileri düğümlerdeki llama.cpp'nin yükleyemediği bir mimari.",
|
||||
"explore.untick": "Onları görmek için “yalnızca bu düğümlerin çalıştırabildikleri” kutusunun işaretini kaldırın.",
|
||||
"explore.gated_note": "Erişimi kısıtlı bir depo, koşullarının bir Hugging Face hesabıyla kabul edilmesini ister; buradaki tarama ise anonimdir — dosyalar listelenir, ancak birini indirmek aynada başarısız olur. Dosyayı elle indirip ayna dizinine koyun ya da aynaya bir token verin.",
|
||||
"explore.empty.query_hidden.other": "“%[2]s” ile eşleşen ve bu düğümlerin çalıştırabileceği bir şey yok — llama.cpp'nin yükleyemediği bir mimari nedeniyle %[1]d sonuç gizlendi.",
|
||||
"explore.empty.query": "“%s” ile eşleşen bir şey yok.",
|
||||
"explore.empty.hidden.other": "Bu düğümlerin çalıştırabileceği listelenecek bir şey yok — llama.cpp'nin yükleyemediği bir mimari nedeniyle %d sonuç gizlendi.",
|
||||
"explore.empty.nothing": "Listelenecek bir şey yok.",
|
||||
|
||||
"mirror.fetch.intro": "Aramanın ulaşamadığı her şey için, elle.",
|
||||
"mirror.fetch.source": "Kaynak URL",
|
||||
"mirror.fetch.source_example": "https://huggingface.co/…/resolve/main/model.gguf",
|
||||
"mirror.fetch.model": "Model",
|
||||
"mirror.fetch.model_example": "qwen2.5-coder-7b",
|
||||
"mirror.fetch.version": "Sürüm",
|
||||
"mirror.fetch.file": "Dosya",
|
||||
"mirror.fetch.file_hint": "URL'den alınır",
|
||||
"mirror.fetch.force": "Yine de indir",
|
||||
"mirror.fetch.force_hint": "düğümlerdeki llama.cpp yükleyemese bile",
|
||||
"mirror.fetch.start": "İndirmeyi başlat",
|
||||
"mirror.fetch.note": "Ayna, indirme bittiğinde değil, başlar başlamaz yanıt verir — bir model onlarca gigabayttır ve burada hiçbir şey bunun için bir bağlantıyı açık tutmaz. Gelişini Aynadakiler sekmesinde görürsünüz. Bir sürümün üzerine asla yazılmaz: var olan bir sürüme indirme reddedilir; böylece yeni bir derleme yeni bir sürüm olur ve eski ağırlıklar yerinde kalır.",
|
||||
|
||||
"card.back": "← listeye dön",
|
||||
"card.at": "%s sürümünde",
|
||||
"card.at_code": "<code>%s</code> sürümünde",
|
||||
"card.from": "temeli:",
|
||||
"card.license": "lisans %s",
|
||||
"card.on_registry": "Hugging Face'te ↗",
|
||||
"card.model_card": "Model kartı",
|
||||
"card.col.what": "Ne",
|
||||
"card.col.size": "Boyut",
|
||||
"card.col.checksum": "Yayımlanan sağlama toplamı",
|
||||
"card.shards": "%d parça, birlikte indirilir",
|
||||
"card.companions": "motorun yanında okuduğu %d dosyayla birlikte",
|
||||
"card.adapter_title": "bir LoRA adaptörü: tek başına değil, yapıldığı %s modeli üzerinde çalışır",
|
||||
"card.adapter": "LoRA adaptörü · %s",
|
||||
"card.model_arch": "model · %s",
|
||||
"card.model": "model",
|
||||
"card.cannot_serve": "sunulamaz",
|
||||
"card.imatrix": "bu bir model değil, nicemleme için bir önem matrisi",
|
||||
"card.fetch": "İndir",
|
||||
"card.weights_note": "Yalnızca ağırlıklar listelenir; bir deponun çoğu bir README ve bir tokenizer'dır. Bir safetensors kümesi, vLLM'in yanında okuduğu dosyalarla — config.json, tokenizer, parça dizini — birlikte indirilir, asla kodla birlikte değil. Bir dosyanın parçaları tek satır ve tek düğmedir — on beş parçalı bir model on beş değil, tek bir karardır. Ayrı iki dosya iki satırdır, çünkü genellikle birbirinin seçeneğidir: ya bir nicemleme ya öteki, ikisi birden değil.",
|
||||
"card.pinned_note": "Kaynak URL'ler bir dala değil <code>%s</code> sürümüne sabitlenmiştir; böylece dal arada değişse bile gelen, burada listelenendir — yukarıdaki tek dosya sağlama toplamı da model deposunun yayımladığıdır ve ayna dosyayı aldığında hesapladığıyla karşılaştırılabilir.",
|
||||
"card.no_weights": "%s içinde ağırlık dosyası yok.",
|
||||
|
||||
"mirror.detail.not_in_catalog": "Katalogda değil: bu sürümü çalıştıran bir şey yok.",
|
||||
"mirror.detail.use_serve": "Hizmete almak için satırındaki <strong>sun</strong> bağlantısını kullanın.",
|
||||
"mirror.detail.ready": "%d / %d hazır",
|
||||
"mirror.detail.in_all": "toplam %d",
|
||||
"mirror.detail.does_not_fit": "sığmıyor",
|
||||
"mirror.replica.col.replica": "Replika",
|
||||
"mirror.replica.col.node": "Düğüm",
|
||||
"mirror.replica.col.state": "Durum",
|
||||
"mirror.replica.col.age": "Süre",
|
||||
"mirror.replica.col.memory": "Bellek",
|
||||
"mirror.replica.placing": "yerleştiriliyor",
|
||||
"mirror.replica.starting": "başlıyor",
|
||||
"mirror.replica.ready": "hazır",
|
||||
"mirror.replica.draining": "boşaltılıyor",
|
||||
"mirror.replica.stopped": "durduruldu",
|
||||
"mirror.replica.failed": "başarısız",
|
||||
"mirror.replica.unknown": "bilinmiyor",
|
||||
"mirror.replica.restart": "yeniden başlatma: %s",
|
||||
"mirror.restart.queued": "sırada",
|
||||
"mirror.restart.replacing": "yenisiyle değiştiriliyor",
|
||||
"mirror.restart.in_place": "yerinde",
|
||||
"mirror.replica.cores": "%s çekirdek",
|
||||
"mirror.detail.none_running": "Şu anda çalışan replika yok.",
|
||||
"mirror.detail.none_running_ondemand": "Şu anda çalışan replika yok: biri istediğinde başlar.",
|
||||
"mirror.room.at_least": "En az %d replika daha için yer var.",
|
||||
"mirror.room.more_on": "Şu anda %d tane daha için yer var: %s.",
|
||||
"mirror.room.more": "Şu anda %d tane daha için yer var.",
|
||||
"mirror.room.none": "Şu anda bir replika daha için boş yer yok.",
|
||||
"mirror.room.and_preempting": "Sınıfının öncelikli olduğu replikalar durdurularak %d tane daha: %s.",
|
||||
"mirror.room.preempting": "Sınıfının öncelikli olduğu replikalar durdurularak %d tane: %s.",
|
||||
"mirror.room.why_not_more": "Neden daha fazla değil",
|
||||
"mirror.room.why_not_another": "Neden bir tane daha değil",
|
||||
"mirror.adapters.heading": "LoRA adaptörleri",
|
||||
"mirror.adapters.find": "Hugging Face'te adaptör bul →",
|
||||
"mirror.adapters.asked_as": "İstendiği ad",
|
||||
"mirror.adapters.file": "Dosya",
|
||||
"mirror.adapters.scale": "Ölçek",
|
||||
"mirror.adapters.none": "Yok. Bir adaptörün adıyla gelen istek, bu modelin replikalarınca adaptör uygulanarak karşılanır.",
|
||||
"mirror.adapters.adapter_file": "Adaptör dosyası",
|
||||
"mirror.adapters.attach": "Ekle",
|
||||
"mirror.adapters.no_choice": "Aynada bu modelin mimarisi için adaptör dosyası yok. Önce Hugging Face'ten bir tane indirin.",
|
||||
"mirror.detail.declared": "server.yaml içinde tanımlı; bu dosya her başlangıçta yeniden okunur: replikalarını orada değiştirin.",
|
||||
"mirror.form.min": "Sürekli çalışan",
|
||||
"mirror.form.max": "Yük altında en çok",
|
||||
"mirror.form.class": "Sınıf",
|
||||
"mirror.class.default": "varsayılan (%s)",
|
||||
"mirror.class.critical": "kritik",
|
||||
"mirror.class.standard": "standart",
|
||||
"mirror.class.opportunistic": "fırsatçı",
|
||||
"mirror.form.owner": "Sahip",
|
||||
"mirror.form.shared": "ortak",
|
||||
"mirror.form.memory": "Bellek GB",
|
||||
"mirror.form.per_replica": "replika başına",
|
||||
"mirror.form.cores": "Çekirdek",
|
||||
"mirror.form.cores_hint": "replika başına, iş parçacıkları",
|
||||
"mirror.form.most_now": "Şu anda en çok %d tanesi sürekli çalışabilir.",
|
||||
"mirror.form.most_now_preempting": "Şu anda en çok %d tanesi sürekli çalışabilir; sınıfının yer açmak için durdurabileceği replikalar da sayıldı.",
|
||||
"mirror.form.note": "Sürekli çalışan 0 ve daha yüksek bir üst sınır, modeli istendiğinde yükler; 0 ve 0 onu durdurur. İstekler beklediğinde yük altında bir replika eklenir ve on dakika sessiz kaldıktan sonra kaldırılır. Daha yüksek bir sınıf, yer açmak için daha düşük sınıfların replikalarını durdurabilir; bellek ve çekirdek ayarları değişiklikten sonra başlayan replikalara uygulanır.",
|
||||
|
||||
"mirror.err.source_required": "bir kaynak URL gerekli",
|
||||
"mirror.err.not_fetched": "indirilmedi: %s. Yine de almak için \"yine de indir\" kutusunu işaretleyin",
|
||||
"mirror.err.partly_started": "%d / %d başladı, ardından %s: %s",
|
||||
"mirror.msg.started": "%s/%s indirilmeye başlandı (%s)",
|
||||
"mirror.err.choose_mode": "bir model ve nasıl çalışacağını seçin",
|
||||
"mirror.msg.ondemand": "%s artık istendiğinde yüklenir ve 15 dakika kullanılmazsa durur",
|
||||
"mirror.msg.stopped": "%s durduruldu; replikaları kapatılıyor",
|
||||
"mirror.msg.always": "%s artık sürekli çalışıyor",
|
||||
"mirror.msg.removed": "%s/%s kaldırıldı (%s)",
|
||||
"mirror.err.which_model": "hangi model?",
|
||||
"mirror.err.replicas_whole": "replika sayıları tam sayı olmalı",
|
||||
"mirror.err.min_over_max": "sürekli çalışan (%d), yük altındaki en yüksek sayıdan (%d) fazla olamaz",
|
||||
"mirror.err.at_most": "en çok %d replika",
|
||||
"mirror.err.declared": "%s, server.yaml içinde tanımlı; orada değiştirin",
|
||||
"mirror.err.no_model": "%q adında model yok",
|
||||
"mirror.err.nothing_fits": "daha fazlası sığmıyor",
|
||||
"mirror.err.no_room": "%s şu anda en çok %d replikayla sürekli çalışabilir (%d çalışıyor, %d tane daha için yer var): %s",
|
||||
"mirror.msg.room_for": "şu anda %d tanesine yer var; bunun ötesine ancak yer açılırsa büyür",
|
||||
"mirror.msg.will_stop": "yer açmak için %s replikaları durdurulacak",
|
||||
"mirror.msg.loads_up_to": "%s istendiğinde yüklenir, yük altında en çok %d",
|
||||
"mirror.msg.runs_n": "%s %s ile çalışıyor",
|
||||
"mirror.msg.runs_up_to": "%s %d replikayla, yük altında en çok %d replikayla çalışıyor",
|
||||
"mirror.err.not_from_mirror": "aynadan çalışan %q adında bir model yok",
|
||||
"mirror.err.memory_whole": "bellek ve çekirdek tam sayı olmalı",
|
||||
"mirror.msg.reserves": "bir replika %d GB ve %d çekirdek ayırır (çalışanlar yeniden başlatılana dek eskisini korur)",
|
||||
"mirror.msg.is_class": "artık %s",
|
||||
"mirror.msg.is_shared": "artık ortak",
|
||||
"mirror.msg.counts_against": "artık %s kotasından sayılıyor",
|
||||
"mirror.err.partly_set": "%s: %s; ancak replikalar değişmedi: %s",
|
||||
"mirror.msg.nothing_changed": "%s: hiçbir şey değişmedi",
|
||||
"mirror.msg.changed": "%s %s",
|
||||
"mirror.err.no_adapter": "%s modelinin %q adında adaptörü yok",
|
||||
"mirror.msg.adapter_removed": "%[1]s: %[2]s adaptörü kaldırıldı; replikalar yeniden başlatıldığında onu bırakır (llmgw ctl restart --model %[1]s)",
|
||||
"mirror.err.scale": "ölçek pozitif bir sayı olmalı",
|
||||
"mirror.err.adapter_name_file": "bir adaptör için ad ve dosya gerekli",
|
||||
"mirror.err.adapter_unreadable": "adaptör dosyası okunamadı: %s",
|
||||
"mirror.err.not_adapter": "bu dosya bir LoRA adaptörü değil, bir model",
|
||||
"mirror.err.wrong_arch": "bu adaptör %s modelleri için; %s ise %s",
|
||||
"mirror.msg.adapter_added": "%[1]s: %[2]s adaptörü eklendi; replikalar yeniden başlatılınca bu adla isteyin (llmgw ctl restart --model %[1]s)",
|
||||
"mirror.err.adapter_op": "bir adaptör ekleyin ya da kaldırın"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"nav.aria": "Ana menü",
|
||||
"nav.org": "Kuruluş",
|
||||
"nav.usage": "Kullanım",
|
||||
"nav.quotas": "Kotalar",
|
||||
"nav.cluster": "Küme",
|
||||
"nav.people": "Kişiler",
|
||||
"nav.mirror": "Ayna",
|
||||
"nav.guard": "Koruma kuralları",
|
||||
"nav.chat": "Sohbet",
|
||||
"nav.account": "Hesap",
|
||||
"nav.sign_out": "Çıkış yap",
|
||||
"nav.docs": "Belgeler"
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"node.last_seen": "son görülme %s önce",
|
||||
"node.never_seen": "hiç görülmedi",
|
||||
"node.nav.cluster": "küme",
|
||||
"node.cores": "%d çekirdek",
|
||||
"node.ram_in_use_of": "kullanılan RAM · toplam %s",
|
||||
"node.load": "yük · 5 dk %s · 15 dk %s",
|
||||
"node.inlet": "giriş havası",
|
||||
"node.not_measured": "CPU ve RAM son zamanlarda ölçülmedi",
|
||||
"node.disk_free": "boş disk · toplam %dG",
|
||||
"node.gpus_free": "boş GPU",
|
||||
|
||||
"node.gpus": "GPU'lar",
|
||||
"node.gpu.busy": "Meşgul",
|
||||
"node.gpu.memory_used": "Kullanılan bellek",
|
||||
"node.gpu.temp": "Sıcaklık",
|
||||
"node.gpu.memory_temp": "Bellek sıc.",
|
||||
"node.gpu.power": "Güç",
|
||||
"node.gpu.clock": "Saat hızı",
|
||||
"node.gpu.held_back": "Kısıtlayan ve yıpranma",
|
||||
"node.gpu.memory_util": "bellek %s%%",
|
||||
"node.of_gb": "/ %s GB",
|
||||
"node.of": "/ %s",
|
||||
"node.gpu.nothing": "hiçbir şey",
|
||||
"node.gpu.ecc": "%s düzeltilemeyen ECC hatası",
|
||||
"node.gpu.pcie": "%s PCIe yeniden denemesi",
|
||||
|
||||
"node.fabric": "Fabric",
|
||||
"node.fabric.on": "Bağlı olduğu: %s",
|
||||
"node.fabric.map": "harita →",
|
||||
"node.fabric.none": "Hiçbir fabric'te değil: tanımlı bir fabric'in adını taşıyan hiçbir port trafik taşıyamıyor, bu yüzden buraya çok düğümlü replika yerleştirilmez.",
|
||||
"node.ib.adapter": "Bağdaştırıcı",
|
||||
"node.ib.rate": "Hız",
|
||||
"node.ib.physically": "Fiziksel olarak",
|
||||
"node.ib.errors": "Açılıştan beri hatalar",
|
||||
"node.ib.sent": "Gönderilen",
|
||||
"node.ib.received": "Alınan",
|
||||
"node.unknown": "bilinmiyor",
|
||||
"node.none": "yok",
|
||||
"node.ib.note": "Her sinyalde yeniden okunur. Kapanan bir port, fabric'ini birkaç saniye içinde bu düğümden düşürür; geri gelen port onu geri getirir. Hatalar bağdaştırıcı açıldığından beri birikir: bakılması gereken, artan sayıdır.",
|
||||
|
||||
"node.nvl.title": "GPU'lar ve NVLink",
|
||||
"node.nvl.domain": "<code>%s</code> NVLink alanında: tensör paralelliği bu düğüme ve alanın geri kalanına yayılabilir.",
|
||||
"node.nvl.ends_here": "NVLink bu makinede biter.",
|
||||
"node.imex.active": "etkin",
|
||||
"node.imex.channels.other": "%d kanal",
|
||||
"node.imex.not_installed": "kurulu değil",
|
||||
"node.nvl.matrix": "GPU'lar arasında NVLink",
|
||||
"node.nvl.over_nvlink": "GPU %d ↔ GPU %d, NVLink üzerinden",
|
||||
"node.nvl.over_pcie": "GPU %d ↔ GPU %d, PCIe üzerinden",
|
||||
"node.gpu.name": "Ad",
|
||||
"node.gpu.compute": "Hesaplama",
|
||||
"node.gpu.numa": "NUMA",
|
||||
"node.gpu.nearest": "En yakın bağdaştırıcı",
|
||||
"node.gpu.multinode": "Çok düğümlü NVLink",
|
||||
"node.gpu.joined": "katıldı",
|
||||
"node.gpu.bandwidth": "%s bant genişliği",
|
||||
"node.nvl.note": "NV, NVLink ile birbirine bağlı bir kart çiftidir; NV çiftleriyle sınırlı tutulan bir tensör paralel grup, all-reduce işlemlerini NVLink hızında yapar ve yerleştirme onu bu şekilde seçer. En yakın bağdaştırıcı, bir kartın düğümler arası trafiğinin gitmesi gereken yerdir: PIX ve PXB aynı PCIe anahtarının altındadır, SYS ise işlemciler arasından geçer.",
|
||||
|
||||
"node.runs": "Burada ne çalışıyor",
|
||||
"node.memory": "Bellek",
|
||||
"node.cores_f": "%s çekirdek",
|
||||
"node.no_engines": "Bu düğüme yerleştirilmiş motor yok.",
|
||||
"node.engines": "Motorlar: %s",
|
||||
"node.engines_none": "Motorlar: hiçbiri kurulu değil",
|
||||
"node.model_files": "diskte %d model dosyası",
|
||||
|
||||
"node.last": "Son %s",
|
||||
"node.chart.cpu_note": "Bütün çekirdekler birlikte, düğümün sinyalinden.",
|
||||
"node.chart.ram": "Kullanılan RAM",
|
||||
"node.chart.ram_note": "Toplamdan, çekirdeğin yeni bir sürece verebileceği miktar çıkarılmış hâli.",
|
||||
"node.chart.cores": "Meşgul çekirdekler, modele göre",
|
||||
"node.chart.cores_note": "Her motorun süreç grubu, top'un saydığı gibi.",
|
||||
"node.chart.engine_memory": "Motor belleği, modele göre",
|
||||
"node.chart.engine_memory_note": "Diskten eşlenen ağırlıklar dâhil yerleşik bellek. Çekirdek o sayfaları geri alabilir, bu yüzden bu değer kullanılan RAM'i aşabilir.",
|
||||
"node.chart.gpu_busy": "Meşgul GPU, karta göre",
|
||||
"node.chart.gpu_busy_note": "Her örnekleme süresinde bir çekirdek işlevinin çalıştığı pay. Bir replikanın tuttuğu ve burada boşta görünen kart, kimsenin istemediği karttır.",
|
||||
"node.chart.gpu_memory": "Kullanılan GPU belleği, karta göre",
|
||||
"node.chart.gpu_memory_note": "Kullanımdaki çerçeve arabelleği: bir motorun ayırdığı ağırlıklar ve KV önbelleği, ayrıca llmgw'ye ait olmayan her şey.",
|
||||
"node.chart.gpu_temp": "GPU sıcaklığı, °C",
|
||||
"node.chart.gpu_temp_note": "Kısma genellikle 80'lerin üst kısmında başlar; bir kartı neyin kısıtladığını yukarıdaki tabloda görün.",
|
||||
"node.chart.gpu_power": "GPU gücü, W",
|
||||
"node.chart.gpu_power_note": "Kartın uygulanan sınırına göre çekilen güç; sınırdayken sw_power_cap görünür.",
|
||||
"node.chart.load": "Yük ortalaması",
|
||||
"node.chart.load_note": "Çekirdek sayısının üstünde olması, işin bir CPU beklediği anlamına gelir.",
|
||||
"node.chart.queue_note": "Kabul edilmiş ama henüz başlamamış istekler, şu an buradaki replikalarda.",
|
||||
"node.no_store": "Yapılandırılmış bir metrik deposu yok, bu yüzden burada geçmiş yok; sunucu yapılandırmasındaki <code>monitoring.prometheus</code> bunu ekler.",
|
||||
|
||||
"logs.title": "%s günlükleri",
|
||||
"logs.agent": "ajanın günlüğü",
|
||||
"logs.engine": "<code>%s</code> motorunun çıktısı",
|
||||
"logs.last_lines": "son %d satır",
|
||||
"logs.lines": "satır",
|
||||
"logs.stop": "izlemeyi bırak",
|
||||
"logs.follow": "izle",
|
||||
"logs.follow_every": "(5 saniyede bir yenilenir)",
|
||||
"logs.empty": "Henüz bir şey kaydedilmedi.",
|
||||
"logs.cannot_read": "bu kontrol düzlemi düğüm günlüklerini okuyamıyor",
|
||||
|
||||
"nodeengine.notice.auto": "%s, %s motorunu donanımının çalıştırdığı derlemeyle kuracak; düğüme şimdi bildirildi, kurulumu arka planda yapacak",
|
||||
"nodeengine.notice.none": "%s artık %s kurmayacak; düğüme şimdi bildirildi, değişikliği arka planda yapacak",
|
||||
"nodeengine.notice.build": "%s, %s motorunu %s için derlenmiş hâliyle kuracak; düğüme şimdi bildirildi, kurulumu arka planda yapacak"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"org.sub.other": "%d kişi · kuruluş genelinde kullanım, üyeler ve sınırlar",
|
||||
"org.filter": "Kuruluş",
|
||||
|
||||
"org.use": "Kullanım",
|
||||
"org.requests_note": "Bu kuruluşun projelerinde yapılan istekler.",
|
||||
"org.tokens_note": "İstem ve yanıt birlikte.",
|
||||
"org.spend_note": "Tam birim cinsinden; bütçelerin yazıldığı birim.",
|
||||
"org.col.person": "Kişi",
|
||||
"org.col.project": "Proje",
|
||||
"org.col.team": "Ekip",
|
||||
"org.use_empty": "Bu dönemde hiçbir şey kullanılmadı.",
|
||||
"org.use_scope": "Yalnızca bu kuruluşun projelerinde harcananlar. Kişilerinin başka yerlerde — başka bir kuruluşta ya da kendi başlarına — yaptıkları gösterilmez.",
|
||||
|
||||
"org.limits.title": "Ekipler, projeler ve sınırlar",
|
||||
"org.col.spent_30d": "Harcanan · 30 gün",
|
||||
"org.limits.note": "Sınırları kümenin yöneticileri belirler.",
|
||||
|
||||
"org.members.title": "Üyeler",
|
||||
"org.col.member_of": "Üyesi olduğu yer",
|
||||
"org.col.state": "Durum",
|
||||
"org.role.manager": "yönetici",
|
||||
"org.state.pending": "onay bekliyor",
|
||||
"org.state.active": "etkin",
|
||||
"org.state.suspended": "askıya alındı",
|
||||
"org.members.empty": "Henüz üye yok.",
|
||||
"org.whole": "kuruluşun tamamı",
|
||||
|
||||
"org.none.title": "Kuruluşlar",
|
||||
"org.none.sub": "Bu kümede hiç kuruluş yok",
|
||||
"org.none.about": "Herkes ağ geçidini kendi kişisel kotası altında, kendi başına kullanıyor. Bir kuruluş oluşturulduğunda, yöneticileri için burada bir sayfası olur: ekibe, projeye ve kişiye göre kullanımı, sınırları ve üyeleri.",
|
||||
"org.none.how": "<a href=\"/admin/users\">Kişiler sayfasında bir kuruluş oluşturun</a>, ardından birinin üyeliğindeki yıldızla onu kuruluşun yöneticisi yapın.",
|
||||
|
||||
"org.err.read_tree": "kuruluş okunamadı"
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"people.title": "Kişiler",
|
||||
"people.sub": "Kuruluşlar, ekipler, projeler ve içlerindeki kişiler: her birinin neyi kullanabileceği ve neyi kullandığı",
|
||||
"people.tab.people": "Kuruluşlar ve kişiler",
|
||||
"people.waiting": "%d bekliyor",
|
||||
"people.tab.limits": "Sınırlar",
|
||||
"people.tab.usage": "Kullanım",
|
||||
"people.search.placeholder": "E-posta, ad ya da projeye göre kişi arayın",
|
||||
"people.search.label": "Kişi ara",
|
||||
|
||||
"people.kind.org": "kuruluş",
|
||||
"people.kind.team": "ekip",
|
||||
"people.kind.project": "proje",
|
||||
"people.kind.user": "kişi",
|
||||
"people.close": "Kapat",
|
||||
"people.in": "Bulunduğu yerler:",
|
||||
"people.on_own": "Tek başına: hiçbir kuruluşta, ekipte ya da projede değil.",
|
||||
|
||||
"people.limits": "Sınırlar",
|
||||
"people.budgets_unpriced": "Hiçbir modelin fiyatı yok, bu yüzden bütçeler sıfırda kalır; hız ve token sınırları geçerlidir.",
|
||||
"people.rpm": "İstek/dk",
|
||||
"people.tpm": "Token/dk",
|
||||
"people.budget": "Bütçe",
|
||||
"people.spent": "Harcanan, %s",
|
||||
"people.models": "Modeller",
|
||||
"people.models_only": "yalnızca <code>%s</code>",
|
||||
"people.models_limited_by": "%s tarafından sınırlı",
|
||||
"people.models_every": "tüm modeller",
|
||||
"people.each_member": "Üye başına",
|
||||
"people.change_limits": "sınırları değiştir",
|
||||
"people.set_limits": "sınır koy",
|
||||
"people.quota_for": "%s için kota",
|
||||
"people.note.shared": "Altındaki her şey bunu paylaşır.",
|
||||
"people.limit_each_member": "üye başına sınır koy",
|
||||
"people.each_member_of": "%s üyelerinin her biri için ayrı ayrı",
|
||||
"people.note.each_member": "Her üye burada bu kadarını alır; diğerlerinden ayrı sayılır.",
|
||||
"people.everywhere": "%s, her yerde",
|
||||
"people.note.person": "Bu kişinin tüm anahtarları, kullandığı tüm projeler ve sohbet sayfası genelinde.",
|
||||
|
||||
"people.capacity": "Kapasite",
|
||||
"people.cores": "Çekirdek",
|
||||
"people.memory_gb": "Bellek GB",
|
||||
"people.capacity_note": "Garanti edilenden kullanılan (en fazla sınır kadar).",
|
||||
"people.change": "değiştir",
|
||||
"people.no_capacity": "Kapasite kotası yok: modelleri kümeyi yalnızca sınıflarına göre paylaşır.",
|
||||
"people.set_one": "kota koy",
|
||||
|
||||
"people.use": "Kullanım, %s",
|
||||
"people.n_requests": "%s istek",
|
||||
"people.n_tokens": "%s token",
|
||||
"people.spent_amount": "%s harcandı",
|
||||
"people.n_errors": "%s hata",
|
||||
"people.error_rate": "Hata oranı",
|
||||
"people.refused": "Bir sınıra takılan",
|
||||
"people.first_token": "İlk token",
|
||||
"people.latency": "Gecikme",
|
||||
"people.chart.requests": "İstekler",
|
||||
"people.chart.tokens": "Tokenlar",
|
||||
"people.chart.spend": "Harcama",
|
||||
"people.nothing_used": "Bu aralıkta hiçbir şey kullanılmadı.",
|
||||
|
||||
"users.pending.title": "Onay bekleyenler",
|
||||
"users.col.email": "E-posta",
|
||||
"users.col.name": "Ad",
|
||||
"users.col.signed_up": "Kaydolma",
|
||||
"users.col.approve_into": "Onayla ve şuraya al",
|
||||
"users.project_for": "%s için proje",
|
||||
"users.no_project": "Henüz proje yok: tek başına",
|
||||
"users.approve": "Onayla",
|
||||
"users.reject_confirm": "%s reddedilip kaldırılsın mı?",
|
||||
"users.reject": "Reddet",
|
||||
|
||||
"users.tree.title": "Kuruluşlar, ekipler ve projeler",
|
||||
"users.tree.note": "Kişi bir projede, proje bir ekipte, ekip de bir kuruluşta yer alır. Sınırlarını, kapasitesini ve kullanımını ağacın yanında görmek için bir ad — ya da aşağıdaki listeden bir kişi — seçin; kişilerini ve anahtarlarını görmek için bir projeyi açın.",
|
||||
"users.tree.note_quotas": "Kişi bir projede, proje bir ekipte, ekip de bir kuruluşta yer alır ve bunların her birine <a href=\"/admin/quotas\">sınır</a> konabilir. Sınırlarını, kapasitesini ve kullanımını ağacın yanında görmek için bir ad — ya da aşağıdaki listeden bir kişi — seçin; kişilerini ve anahtarlarını görmek için bir projeyi açın.",
|
||||
"users.empty": "Henüz kuruluş yok. Önce bir kuruluş, sonra içinde bir ekip, sonra da ekipte bir proje oluşturun — bir hesap yalnızca bir projeye alınarak onaylanabilir.",
|
||||
"users.n_teams.other": "%d ekip",
|
||||
"users.rename": "yeniden adlandır",
|
||||
"users.new_name": "Yeni ad",
|
||||
"users.remove_org_confirm": "%s kuruluşu kaldırılsın mı?",
|
||||
"users.remove": "kaldır",
|
||||
"users.n_projects.other": "%d proje",
|
||||
"users.move": "taşı",
|
||||
"users.move_team_to": "%s ekibinin taşınacağı kuruluş",
|
||||
"users.move_button": "Taşı",
|
||||
"users.remove_team_confirm": "%s ekibi kaldırılsın mı?",
|
||||
"users.limits_of": "%s için sınırlar ve kullanım",
|
||||
"users.limits_and_use": "sınırlar ve kullanım",
|
||||
"users.n_members.other": "%d üye",
|
||||
"users.n_keys.other": "%d anahtar",
|
||||
"users.n_revoked": "(%d iptal edildi)",
|
||||
"users.move_project": "başka bir ekibe taşı",
|
||||
"users.move_project_to": "%s projesinin taşınacağı ekip",
|
||||
"users.remove_project_confirm": "%s projesi kaldırılsın mı?",
|
||||
"users.not_removable": "İçinde kimse kalmadığında ve hiç anahtar verilmemişse kaldırılabilir.",
|
||||
"users.col.member": "Üye",
|
||||
"users.col.state": "Durum",
|
||||
"users.col.last_sign_in": "Son giriş",
|
||||
"users.no_members": "Kendi üyesi yok. Ekibinin ya da kuruluşunun üyeleri de bunu kullanabilir.",
|
||||
"users.col.key": "Anahtar",
|
||||
"users.col.owner": "Sahibi",
|
||||
"users.col.last_used": "Son kullanım",
|
||||
"users.issued_by_operator": "bir operatör verdi",
|
||||
"users.key_state.active": "etkin",
|
||||
"users.key_state.revoked": "iptal edildi",
|
||||
"users.key_state.expired": "süresi doldu",
|
||||
"users.revoke_confirm": "%s anahtarı iptal edilsin mi? Onu kullanan her şey çalışmayı bırakır.",
|
||||
"users.revoke": "iptal et",
|
||||
"users.add_project": "+ proje",
|
||||
"users.new_project_in": "%s içinde yeni proje",
|
||||
"users.new_project_in_label": "%s içinde yeni proje",
|
||||
"users.add_team": "+ ekip",
|
||||
"users.new_team_in": "%s içinde yeni ekip",
|
||||
"users.new_team_in_label": "%s içinde yeni ekip",
|
||||
"users.new_org": "yeni kuruluş",
|
||||
"users.new_org_label": "Yeni kuruluş",
|
||||
"users.add_org": "Kuruluş ekle",
|
||||
|
||||
"users.accounts": "Tüm hesaplar",
|
||||
"users.col.person": "Kişi",
|
||||
"users.col.member_of": "Üyesi olduğu",
|
||||
"users.col.add_to": "Şuraya ekle",
|
||||
"users.alone_note": "Hiçbir yere üye olmayan biri ağ geçidini tek başına kullanır: kişisel anahtarlarla ve sohbet sayfasıyla, kişisel sınırları içinde.",
|
||||
"users.no_accounts": "Henüz hesap yok.",
|
||||
"users.shell_note": "Bilerek burada olmayanlar: birini yönetici yapmak, parola sıfırlamak ve başkası adına anahtar vermek. Her biri çalınmış bir yönetici oturumuna sahip olduğundan fazla erişim kazandırır; bu yüzden kontrol düzlemi üzerinde bir kabuk gerektirirler — <code>llmgw users promote</code>, <code>llmgw users reset-password</code>, <code>llmgw keys create</code>.",
|
||||
|
||||
"users.more_for": "%s için diğer işlemler",
|
||||
"users.more": "Diğer",
|
||||
"users.suspend": "askıya al",
|
||||
"users.reinstate": "yeniden etkinleştir",
|
||||
"users.remove_confirm": "%s kalıcı olarak kaldırılsın mı? Anahtarları iptal edilir.",
|
||||
"users.you": "siz",
|
||||
"users.tag.admin": "yönetici",
|
||||
"users.state.active": "etkin",
|
||||
"users.state.pending": "onay bekliyor",
|
||||
"users.state.suspended": "askıda",
|
||||
"users.role.manager": "sorumlu",
|
||||
"users.stop_managing_of": "%s sorumluluğunu bırak",
|
||||
"users.make_manager_of": "%s sorumlusu yap: sayfasını görebilir",
|
||||
"users.stop_managing": "Sorumluluğu bırak",
|
||||
"users.make_manager": "Sorumlu yap",
|
||||
"users.take_out_confirm": "%[1]s, %[2]s içinden çıkarılsın mı? Orada oluşturduğu anahtarlar iptal edilir.",
|
||||
"users.take_out_of": "%s içinden çıkar",
|
||||
"users.on_own": "tek başına",
|
||||
"users.add_to_label": "%s kişisini şuraya ekle",
|
||||
"users.choose": "seçin…",
|
||||
"users.also_in": "%d yerde daha",
|
||||
"users.take_out_here_confirm": "%s buradan çıkarılsın mı? Burada oluşturduğu anahtarlar iptal edilir.",
|
||||
"users.take_out": "çıkar",
|
||||
"users.person_to_add": "Eklenecek kişi",
|
||||
"users.choose_person": "bir kişi seçin…",
|
||||
"users.add_member": "Üye ekle",
|
||||
"users.nobody_in": "%s içinde doğrudan üye kimse yok; buradaki bir üye içindeki tüm projeleri kullanabilir.",
|
||||
|
||||
"users.msg.about": "%s: %s",
|
||||
"users.msg.approved_alone": "onaylandı; şimdilik tek başına",
|
||||
"users.msg.approved_into": "onaylandı ve %s projesine alındı",
|
||||
"users.msg.suspended": "askıya alındı ve her yerde oturumu kapatıldı",
|
||||
"users.msg.reinstated": "yeniden etkinleştirildi",
|
||||
"users.msg.added_to": "%s içine eklendi",
|
||||
"users.msg.now_manages": "artık %s sorumlusu",
|
||||
"users.msg.no_longer_manages": "artık %s sorumlusu değil",
|
||||
"users.msg.removed_from": "%s içinden çıkarıldı",
|
||||
"users.msg.removed_from_one_key": "%s içinden çıkarıldı; orada oluşturduğu 1 anahtar iptal edildi",
|
||||
"users.msg.removed_from_keys": "%s içinden çıkarıldı; orada oluşturduğu %d anahtar iptal edildi",
|
||||
"users.msg.removed_keys_revoked": "kaldırıldı; anahtarları iptal edildi",
|
||||
"users.msg.org_created": "%s kuruluşu oluşturuldu",
|
||||
"users.msg.team_created": "%s ekibi oluşturuldu",
|
||||
"users.msg.project_created": "%s projesi oluşturuldu",
|
||||
"users.msg.renamed": "yeni adı: %s",
|
||||
"users.msg.team_moved": "ekip taşındı",
|
||||
"users.msg.project_moved": "proje taşındı: %s",
|
||||
"users.msg.removed": "kaldırıldı",
|
||||
"users.msg.member_added": "%s, %s içine eklendi",
|
||||
"users.msg.key_revoked": "anahtar iptal edildi; çalışan bir sunucu, önbelleğinin süresi dolunca onu kabul etmeyi bırakır",
|
||||
|
||||
"users.err.about": "%s: %s",
|
||||
"users.err.self_suspend": "kendi hesabınızı askıya alamazsınız",
|
||||
"users.err.choose_where": "nereye gideceğini seçin",
|
||||
"users.err.manager_role": "sorumluluk, bir kuruluştaki roldür",
|
||||
"users.err.nothing_to_leave": "çıkılacak bir yer yok",
|
||||
"users.err.self_remove": "kendi hesabınızı kaldıramazsınız",
|
||||
"users.err.choose_org": "bu ekibin ait olduğu kuruluşu seçin",
|
||||
"users.err.choose_team": "bu projenin ait olduğu ekibi seçin",
|
||||
"users.err.no_such_kind": "bu türde bir şey yok",
|
||||
"users.err.choose_destination": "nereye taşınacağını seçin",
|
||||
"users.err.only_team_project_moves": "yalnızca bir ekip ya da proje taşınabilir",
|
||||
"users.err.choose_who": "kimin ekleneceğini seçin",
|
||||
"users.err.duplicate": "burada bu adla zaten bir tane var",
|
||||
"users.err.not_empty": "kaldırılmadı: %s",
|
||||
"users.err.gone": "bu artık yok; sayfa eskimişti",
|
||||
"users.err.read_projects": "projeler okunamadı",
|
||||
"users.err.read_accounts": "hesaplar okunamadı",
|
||||
"users.err.read_memberships": "üyelikler okunamadı",
|
||||
"users.err.read_keys": "anahtarlar okunamadı",
|
||||
"users.err.read_tree": "ağaç okunamadı",
|
||||
"users.err.read_orgs": "kuruluşlar okunamadı",
|
||||
"users.err.read_teams": "ekipler okunamadı"
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"quotas.unpriced": "Hiçbir modelin fiyatı yok; bu yüzden hiçbir şey ücretlendirilmez ve ne kadar kullanılırsa kullanılsın bütçeler sıfırda kalır. İstek ve token sınırları yine de geçerlidir. Bütçelerin işlemesi için sunucu yapılandırmasının <code>pricing</code> bölümünde fiyat belirleyin.",
|
||||
|
||||
"quotas.since": "30 gün",
|
||||
"quotas.col.where": "Nerede",
|
||||
"quotas.col.rpm": "İstek/dk",
|
||||
"quotas.col.tpm": "Token/dk",
|
||||
"quotas.col.budget": "Bütçe",
|
||||
"quotas.col.spent": "Harcanan",
|
||||
"quotas.col.person": "Kişi",
|
||||
|
||||
"quotas.kind.org": "kuruluş",
|
||||
"quotas.kind.team": "ekip",
|
||||
"quotas.kind.project": "proje",
|
||||
"quotas.kind.user": "kişi",
|
||||
"quotas.kind.org-member": "kuruluş üyesi",
|
||||
"quotas.kind.team-member": "ekip üyesi",
|
||||
"quotas.kind.project-member": "proje üyesi",
|
||||
|
||||
"quotas.only_models": "yalnızca <code>%s</code>",
|
||||
"quotas.models_from": "modelleri sınırlayan: %s",
|
||||
"quotas.each_member": "buradaki her üye: %s",
|
||||
"quotas.pct_of_budget": "bütçesinin %%%d kadarı",
|
||||
"quotas.pct": "%%%d",
|
||||
"quotas.close": "kapat",
|
||||
"quotas.change": "değiştir",
|
||||
"quotas.set": "belirle",
|
||||
"quotas.per_member": "üye başına",
|
||||
"quotas.per_member_title": "Buradaki her üyenin ayrı ayrı sahip olduğu sınır",
|
||||
|
||||
"quotas.member_form.title": "%s içindeki her üye, ayrı ayrı",
|
||||
"quotas.member_form.note": "Her üye burada bu kadarını alır ve diğerlerinden ayrı sayılır: tek bir ortak havuz değil, adil bir pay.",
|
||||
|
||||
"quotas.form.title": "<strong>%s</strong> kotası; altındaki her şey tarafından paylaşılır. Boş bırakılan alan, o türden sınır olmadığı anlamına gelir; sıfır sınırı anlamına gelmez.",
|
||||
"quotas.form.rpm": "İstek / dakika",
|
||||
"quotas.form.tpm": "Token / dakika",
|
||||
"quotas.form.up_from": "%[2]s sınırı: %[1]s",
|
||||
"quotas.form.per": "dönem",
|
||||
"quotas.per.month": "ay",
|
||||
"quotas.per.day": "gün",
|
||||
"quotas.form.models": "Yalnızca bu modeller",
|
||||
"quotas.form.models_hint": "virgülle ayırın; boş bırakılırsa hepsine izin verilir",
|
||||
"quotas.form.every_model": "tüm modeller",
|
||||
"quotas.form.remove_all": "Buradaki tüm sınırları kaldır",
|
||||
|
||||
"quotas.legend": "<span class=\"q-up\">60 ↑</span>, daha yukarıda belirlenmiş bir sınırdan gelir; nereden geldiğini görmek için üzerine gelin. Burada belirlenen değerler normal yazıyla gösterilir.",
|
||||
"quotas.empty_tree": "Henüz kota verilecek bir şey yok. Önce <a href=\"/admin/users\">Kuruluşlar ve kişiler</a> sekmesinde bir kuruluş oluşturun.",
|
||||
|
||||
"quotas.people.title": "Kişiler",
|
||||
"quotas.people.note": "Bir kişinin kendi sınırı, tüm anahtarlarında, projelerinde ve sohbet sayfasında yaptığı her şeyi kapsar.",
|
||||
"quotas.person_form.title": "%s, her yerde",
|
||||
"quotas.person_form.note": "Bu kişinin sahip olduğu her anahtar, kullandığı her proje ve sohbet sayfası genelinde.",
|
||||
"quotas.people.empty": "Henüz kimse onaylanmadı.",
|
||||
|
||||
"quotas.explain.summary": "Sınırlar nasıl birleşir",
|
||||
"quotas.explain.chain": "Bir dal boyunca en kısıtlayıcı değer geçerli olur; bu yüzden bir ekip kuruluşunun belirlediğini, bir proje de ekibininkini yükseltemez. Sınırı hiçbir şeyle bağlı olmayan tek şey kuruluşun kendi sınırıdır. Model listeleri kesişir: bir proje, ekibinin izin vermediği bir modele izin veremez.",
|
||||
"quotas.explain.keys": "Anahtar başına sınırlar burada belirlenmez: bir anahtar projesiyle sınırlıdır ve her çağıranın verimini bir yönetici oturumundan tek tıklık mesafeye koymak bu sayfanın işi değildir — <code>llmgw keys limits --scope key</code>.",
|
||||
|
||||
"quotas.qform.title": "<strong>%s</strong>. %s Boş bırakılan alan, o türden sınır olmadığı anlamına gelir.",
|
||||
"quotas.qform.remove": "Bu sınırları kaldır",
|
||||
"quotas.set_on": "belirlendiği yer: %s",
|
||||
|
||||
"quotas.capped.rpm": "dakikada %s istek",
|
||||
"quotas.capped.tpm": "dakikada %s token",
|
||||
"quotas.capped.budget": "%s bütçe",
|
||||
"quotas.and": " ve ",
|
||||
"quotas.shadowed.above": "üst düzeyde %s hâlâ geçerli",
|
||||
"quotas.shadowed.by": "%[1]s bunu %[2]s ile sınırlıyor",
|
||||
|
||||
"quotas.desc.rpm": "istek/dk %s",
|
||||
"quotas.desc.tpm": "token/dk %s",
|
||||
"quotas.desc.budget": "bütçe %s",
|
||||
"quotas.desc.models": "modeller %s",
|
||||
"quotas.desc.none": "sınır yok",
|
||||
|
||||
"quotas.personal.off": "Kişisel kullanım kapalı: kişiler ağ geçidini yalnızca projeleri üzerinden kullanır.",
|
||||
"quotas.personal.none": "Kişisel kullanımın varsayılan bir sınırı yok: kendi sınırı olmayan biri yalnızca küme genelindeki varsayılanlarla sınırlıdır.",
|
||||
"quotas.personal.held": "Kendi sınırı olmayan bir kişinin kişisel kullanımı şununla sınırlıdır: %s.",
|
||||
|
||||
"quotas.msg.cleared": "%s %s: buradaki tüm sınırlar kaldırıldı",
|
||||
"quotas.msg.saved": "%s %s: %s",
|
||||
"quotas.err.scope": "kota bir kuruluşa, ekibe, projeye ya da kişiye ait olur",
|
||||
"quotas.err.which": "hangisi?",
|
||||
"quotas.err.rpm": "dakikadaki istek sayısı tam sayı olmalı ya da sınır olmaması için boş bırakılmalı",
|
||||
"quotas.err.tpm": "dakikadaki token sayısı tam sayı olmalı ya da sınır olmaması için boş bırakılmalı",
|
||||
"quotas.err.budget": "bütçe bir sayı olmalı ya da bütçe olmaması için boş bırakılmalı",
|
||||
"quotas.err.period": "bütçenin bir dönemi olmalı: gün ya da ay",
|
||||
"quotas.err.read_limits": "sınırlar okunamadı",
|
||||
"quotas.err.read_tree": "kuruluşlar okunamadı",
|
||||
|
||||
"capacity.title": "Kapasite",
|
||||
"capacity.note": "Ağacın bir bölümüne kümeden neyin güvence altında olduğu ve en çok ne tutabileceği: sahip olduğu modellerin ve altındakilerin replikalarından sayılan GPU'lar, CPU çekirdekleri ve bellek. Güvencesi içinde tuttuğu kaynak başka birinin modeli için hiçbir zaman alınmaz; bunun ötesinde boş yeri ödünç alır ve istendiğinde geri verir. Bir modelin sahibi, ayna sayfasındaki kartında belirlenir.",
|
||||
"capacity.col.cores": "Çekirdek",
|
||||
"capacity.col.memory": "Bellek GB",
|
||||
"capacity.col.set_by": "Belirleyen",
|
||||
"capacity.remove": "kaldır",
|
||||
"capacity.legend": "Her değer, tutulan / güvence altındaki miktardır (en çok sınır kadar); — yönetilmiyor demektir.",
|
||||
"capacity.empty": "Kapasite kotası yok: her model kümeyi yalnızca sınıfına göre paylaşır.",
|
||||
"capacity.form.title_change": "<strong>Kapasite kotasını değiştirin.</strong> Boş bırakılan alan yönetilmez; 0 hiç yok demektir.",
|
||||
"capacity.form.title_set": "<strong>Kapasite kotası belirleyin.</strong> Boş bırakılan alan yönetilmez; 0 hiç yok demektir.",
|
||||
"capacity.form.choose": "seçin…",
|
||||
"capacity.form.g_gpus": "Güvence altındaki GPU",
|
||||
"capacity.form.l_gpus": "En çok GPU",
|
||||
"capacity.empty_tree": "Henüz kapasite kotası verilecek bir şey yok. Önce <a href=\"/admin/users\">Kuruluşlar ve kişiler</a> sekmesinde bir kuruluş oluşturun.",
|
||||
|
||||
"capacity.cell.of": "%s / %s",
|
||||
"capacity.cell.at_most": "%s (en çok %s)",
|
||||
|
||||
"capacity.msg.removed": "%s için kapasite kotası kaldırıldı",
|
||||
"capacity.msg.set": "%s için kapasite kotası belirlendi; yarım dakika içinde geçerli olur",
|
||||
"capacity.err.scope": "kotanın nerede geçerli olacağını seçin",
|
||||
"capacity.err.gpus": "güvence altındaki GPU sayısı tam sayı olmalı ya da hiç olmaması için boş bırakılmalı",
|
||||
"capacity.err.cores": "güvence altındaki çekirdek sayısı tam sayı olmalı ya da hiç olmaması için boş bırakılmalı",
|
||||
"capacity.err.memory": "güvence altındaki bellek tam sayı olmalı ya da hiç olmaması için boş bırakılmalı",
|
||||
"capacity.err.limit_gpus": "GPU sınırı tam sayı olmalı ya da hiç olmaması için boş bırakılmalı",
|
||||
"capacity.err.limit_cores": "çekirdek sınırı tam sayı olmalı ya da hiç olmaması için boş bırakılmalı",
|
||||
"capacity.err.limit_memory": "bellek sınırı tam sayı olmalı ya da hiç olmaması için boş bırakılmalı"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"serve.refusal": "Bu hâliyle sunulamaz: %s.",
|
||||
"serve.serving": "<code>%s</code>, <strong>%s</strong> ile sunulacak.",
|
||||
"serve.whole_version": "Sürümün tamamı, %d dosya: düğüm hepsini tek bir dizine indirir ve motora dizin verilir.",
|
||||
"serve.name": "Ad",
|
||||
"serve.aliases": "Diğer adlar",
|
||||
"serve.aliases_hint": "virgülle ayrılmış",
|
||||
"serve.aliases_example": "coder-7b",
|
||||
"serve.replicas": "Replikalar",
|
||||
"serve.runs": "Çalışma",
|
||||
"serve.mode.ondemand": "istek üzerine: istendiğinde yüklenir, 15 dk kullanılmazsa durur",
|
||||
"serve.description": "Ne için",
|
||||
"serve.description_hint": "istemcilere /v1/models içinde gösterilir",
|
||||
"serve.description_example": "genel sohbet, İsveççesi güçlü; 14B, 8B'den yavaş",
|
||||
"serve.context_length": "Bir isteğin kullanabileceği bağlam",
|
||||
"serve.max_output": "Çıktı sınırı",
|
||||
"serve.optional": "isteğe bağlı",
|
||||
"serve.ctx_pool": "Motorun bağlam havuzu",
|
||||
"serve.ctx_pool_hint": "yuvalara bölünür",
|
||||
"serve.placement": "Çalışacağı yer",
|
||||
"serve.place.gpu": "GPU: %s, %d / %d boşta",
|
||||
"serve.place.cpu_slower": "Yalnızca CPU: GPU yok, kat kat yavaş",
|
||||
"serve.place.cpu_only": "CPU: bu kümede GPU yok",
|
||||
"serve.gpus": "Replika başına GPU",
|
||||
"serve.gpus_hint": "tek düğümde",
|
||||
"serve.parallel": "Paralel istek",
|
||||
"serve.parallel_hint": "replika başına",
|
||||
"serve.cpu_ok": "Yine de CPU üzerinde çalıştır: %d GPU boşta ve kullanılmadan kalacak",
|
||||
"serve.memory": "Gereken RAM (GB)",
|
||||
"serve.memory_hint": "yanındaki bağlam havuzu için",
|
||||
"serve.cpu_cores": "CPU çekirdeği",
|
||||
"serve.cpu_cores_hint": "ayrılır; motorun iş parçacıkları",
|
||||
"serve.selector": "Yalnızca şu etiketli düğümler",
|
||||
"serve.selector_hint": "anahtar / değer, isteğe bağlı",
|
||||
"serve.selector_key_example": "role",
|
||||
"serve.selector_value_example": "cpu-large",
|
||||
"serve.add": "Kataloğa ekle",
|
||||
|
||||
"serve.on_cpu": "%s, CPU üzerinde",
|
||||
"serve.on_gpu_one": "%s, 1 adet %s GPU üzerinde",
|
||||
"serve.on_gpus": "%s, tek düğümün %d adet %s GPU'su üzerinde",
|
||||
"serve.host_memory": "%s GB ana makine belleği",
|
||||
"serve.cores.other": "%d çekirdek",
|
||||
"serve.pool_parallel": "%s token bağlam havuzu, %s paralel isteğe bölünmüş (her birine %s)",
|
||||
"serve.pool": "%s token bağlam havuzu",
|
||||
"serve.will_run_as": "Şöyle çalışacak: %s.",
|
||||
"serve.free_gpus_warning": "%s GPU boşta; CPU üzerinde bu model kat kat yavaş çalışır.",
|
||||
|
||||
"serve.pool_note": "Bağlam havuzu motora verilen miktardır; llama.cpp onu paralel yuvalarına böler, bu yüzden tek bir isteğin kullanabileceği sayı yukarıdaki küçük olandır.",
|
||||
"serve.memory_note": "Bellek: %s. Bağlam havuzunu değiştirirseniz bu da KV önbelleğinin payı kadar değişir — bir tahmindir ve düzenlenebilir. Boş bırakılmak yerine doldurulur, çünkü bir CPU profilinin yetersiz kalacak bir kartı yoktur: bir değer olmadan hazır her düğüm adaydır ve ilk seçilen, modeli taşıyabilen değil, zamanlayıcının ilk ulaştığı düğüm olur. Sabitlemeyi tercih ederseniz bir düğüm etiketi aynı işi adla görür.",
|
||||
"serve.basis.estimate": "ağırlıkların bir buçuk katı artı iki",
|
||||
"serve.basis.shape": "ağırlıklar %s GB + %[3]s token için KV önbelleği %[2]s GB (%[4]d katman × %[5]d KV başı) + motor için %[6]s GB",
|
||||
|
||||
"serve.refuse.no_engine": "burada %s dosyalarını okuyan bir şey yok",
|
||||
"serve.refuse.llamacpp": "düğümlerdeki llama.cpp bunu çalıştıramaz: %s",
|
||||
"serve.refuse.declared": "%s adında bir model yapılandırmada tanımlı; ikincisini eklemek yerine onu orada düzenleyin",
|
||||
"serve.warn.not_installed": "hiçbir düğüm %s kurulu olduğunu bildirmiyor ve %s ağırlıkları ona gerek duyar",
|
||||
"serve.warn.no_gpu": "Bu kümede GPU da yok; aynı modelin bir GGUF derlemesi CPU düğümlerinde çalışır",
|
||||
"serve.warn.too_big": "en büyük düğümde %d GB var, bu ise yaklaşık %d GB gerektiriyor",
|
||||
"serve.warn.and": "%s; ayrıca %s",
|
||||
|
||||
"serve.err.name_source": "bir model için ad ve kaynak gerekli",
|
||||
"serve.err.whole_number": "%s tam sayı olmalı",
|
||||
"serve.what.context_length": "bağlam uzunluğu",
|
||||
"serve.what.max_output": "çıktı sınırı",
|
||||
"serve.what.replicas": "replika sayısı",
|
||||
"serve.what.gpus": "replika başına GPU",
|
||||
"serve.what.ctx_pool": "motorun bağlam havuzu",
|
||||
"serve.what.parallel": "paralel istek sayısı",
|
||||
"serve.what.memory": "bellek gereksinimi",
|
||||
"serve.what.cores": "çekirdek sayısı",
|
||||
"serve.err.engine": "hangi motor çalıştıracak?",
|
||||
"serve.err.needs_gpu": "%s GPU üzerinde çalışır; yerleştirmek için bir GPU sınıfı seçin",
|
||||
"serve.err.placement": "%q geçerli bir yerleşim değil",
|
||||
"serve.err.cpu_with_free_gpus.other": "bu kümede %d GPU boşta, %s ise CPU üzerinde çalışacak biçimde ayarlandı; bir GPU yerleşimi seçin ya da \"yine de CPU üzerinde çalıştır\" kutusunu işaretleyin",
|
||||
"serve.msg.added": "%s kataloğa eklendi, %s: %s; bir sonraki geçişte yerleştirilecek",
|
||||
"serve.replicas_n.other": "%d replika"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"usage.group_by": "Gruplama",
|
||||
"usage.window": "Dönem",
|
||||
"usage.group.org": "kuruluş",
|
||||
"usage.group.team": "ekip",
|
||||
"usage.group.project": "proje",
|
||||
"usage.group.user": "kişi",
|
||||
"usage.group.model": "model",
|
||||
"usage.window.24h": "son 24 saat",
|
||||
"usage.window.7d": "son 7 gün",
|
||||
"usage.window.30d": "son 30 gün",
|
||||
|
||||
"usage.requests": "İstekler",
|
||||
"usage.requests_note": "Her dilimde sunulan istekler; anahtarın o sırada nerede olduğuna göre değil, şu an nereye ait olduğuna göre sayılır.",
|
||||
"usage.tokens": "Tokenlar",
|
||||
"usage.tokens_note": "İstem ve yanıt birlikte; bir kişinin tükettiği miktar olarak tanıdığı rakam budur.",
|
||||
"usage.spend": "Harcama",
|
||||
"usage.spend_note": "Tam birim cinsinden; bütçelerin yazıldığı birimle aynı.",
|
||||
"usage.unpriced": "Hiçbir modelin fiyatı yok; bu yüzden her istek sıfıra mal olur ve küme ne kadar yoğun olursa olsun bu grafik düz bir çizgi olurdu. Sunucu yapılandırmasının <code>pricing</code> bölümünde fiyat belirleyin.",
|
||||
|
||||
"usage.col.group": "Grup",
|
||||
"usage.errors": "Hatalar",
|
||||
"usage.col.error_rate": "Hata oranı",
|
||||
"usage.col.error_rate_title": "Başarısız istekler; bir sınır nedeniyle reddedilenler dahil",
|
||||
"usage.col.limited": "Sınırlanan",
|
||||
"usage.col.limited_title": "İstek, token ya da bütçe sınırı nedeniyle reddedilenler",
|
||||
"usage.col.ttft": "İlk token p50 / p95",
|
||||
"usage.col.ttft_title": "Akışlı isteklerde ilk tokene kadar geçen süre",
|
||||
"usage.col.latency": "Gecikme p95",
|
||||
"usage.pct": "%%%s",
|
||||
"usage.no_owner_keys": "sahibi olmayan anahtarlar",
|
||||
"usage.unknown": "bilinmiyor",
|
||||
"usage.empty": "Bu dönemde kaydedilmiş bir şey yok.",
|
||||
|
||||
"usage.gpu.title": "Sahibine göre GPU süresi",
|
||||
"usage.gpu.chart": "Sahibine göre tutulan GPU'lar",
|
||||
"usage.gpu.chart_note": "Her sahibin modellerinin replikalarının tuttuğu kartlar: sahibi kota ağacının bir parçası olan modeller.",
|
||||
"usage.col.owner": "Sahip",
|
||||
"usage.col.gpu_hours": "GPU-saat",
|
||||
"usage.col.held_now": "Şu an tutulan",
|
||||
"usage.no_owner": "sahipsiz",
|
||||
"usage.gpu.note": "Geçmişi ayarlandığı süre boyunca (varsayılan olarak on beş gün) saklayan metrik deposundan; sahibi olmayan bir model “sahipsiz” altında sayılır.",
|
||||
|
||||
"usage.note.charts": "Grafikler toplamda en yoğun dört grubu çizer ve geri kalanını “diğer” altında toplar; böylece toplamları her zaman kümenin tamamına eşittir. Aşağıdaki tablo, aynı sırayla tüm grupları gösterir.",
|
||||
"usage.note.person": "Kişi, kota ağacının bir düzeyi değildir — bir kişi birkaç projede anahtar tutabilir — bu yüzden kişiye göre gruplama ağacın bir düzeyini adlandırmaz, onu çaprazlamasına keser. Kotalar diğer üç düzeyde, <a href=\"/admin/quotas\">Sınırlar</a> sekmesinde belirlenir.",
|
||||
|
||||
"usage.err.read": "kullanım okunamadı"
|
||||
}
|
||||
+127
-62
@@ -3,7 +3,6 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -55,16 +54,21 @@ type heldModel struct {
|
||||
// MostNow is how many replicas could always run, Current plus the room
|
||||
// found; zero when the room was not measured or had no end in sight.
|
||||
MostNow int
|
||||
// RoomText and PreemptText say the headroom in a sentence each, built
|
||||
// here because the nodes it names are a list inside the sentence.
|
||||
RoomText, PreemptText string
|
||||
}
|
||||
|
||||
// heldReplica is one replica, by the nodes it runs on.
|
||||
type heldReplica struct {
|
||||
ID string
|
||||
State string
|
||||
Restart string
|
||||
Age int64
|
||||
Endpoint string
|
||||
Nodes []heldShard
|
||||
ID string
|
||||
State string
|
||||
// StateText is State as the page says it; Restart is already worded.
|
||||
StateText string
|
||||
Restart string
|
||||
Age int64
|
||||
Endpoint string
|
||||
Nodes []heldShard
|
||||
}
|
||||
|
||||
type heldShard struct {
|
||||
@@ -79,7 +83,7 @@ func openKey(model, version string) string { return model + ":" + version }
|
||||
|
||||
// heldDetailFor reads, for each model using a row, how it runs and how much
|
||||
// more room it has.
|
||||
func (p *AccountPages) heldDetailFor(ctx context.Context, row mirrorRow) *heldDetail {
|
||||
func (p *AccountPages) heldDetailFor(ctx context.Context, l *Lang, row mirrorRow) *heldDetail {
|
||||
d := &heldDetail{}
|
||||
if p.capacity != nil {
|
||||
d.Scopes, _ = p.capacity.Scopes(ctx)
|
||||
@@ -104,8 +108,8 @@ func (p *AccountPages) heldDetailFor(ctx context.Context, row mirrorRow) *heldDe
|
||||
for _, rep := range p.fleet.Replicas(u.Name) {
|
||||
// Built field by field: a ReplicaView carries the engine's key,
|
||||
// which is for the admin listener and never for a page.
|
||||
hr := heldReplica{ID: rep.ID, State: rep.State, Restart: rep.Restart,
|
||||
Age: rep.AgeSec, Endpoint: rep.Endpoint}
|
||||
hr := heldReplica{ID: rep.ID, State: rep.State, StateText: replicaState(l, rep.State),
|
||||
Restart: restartState(l, rep.Restart), Age: rep.AgeSec, Endpoint: rep.Endpoint}
|
||||
for _, sh := range rep.Shards {
|
||||
hr.Nodes = append(hr.Nodes, heldShard{Node: sh.Node, MemoryGB: memory[sh.Node],
|
||||
CPU: sh.CPUCores, MemBytes: sh.MemoryBytes})
|
||||
@@ -118,6 +122,7 @@ func (p *AccountPages) heldDetailFor(ctx context.Context, row mirrorRow) *heldDe
|
||||
if !h.Limited {
|
||||
hm.MostNow = h.Current + h.More + h.ByPreempting
|
||||
}
|
||||
hm.RoomText, hm.PreemptText = roomText(l, h)
|
||||
}
|
||||
}
|
||||
d.Models = append(d.Models, hm)
|
||||
@@ -125,6 +130,75 @@ func (p *AccountPages) heldDetailFor(ctx context.Context, row mirrorRow) *heldDe
|
||||
return d
|
||||
}
|
||||
|
||||
// replicaStates are the states a replica reports, each with its word.
|
||||
var replicaStates = []string{"placing", "starting", "ready", "draining", "stopped", "failed", "unknown"}
|
||||
|
||||
func init() {
|
||||
for _, s := range replicaStates {
|
||||
registerKeys("mirror.replica." + s)
|
||||
}
|
||||
registerKeys("mirror.mode.always", "mirror.mode.ondemand", "mirror.mode.stopped",
|
||||
"mirror.class.critical", "mirror.class.standard", "mirror.class.opportunistic")
|
||||
}
|
||||
|
||||
// replicaState is a replica's state in words; one this page does not know
|
||||
// is shown as the control plane said it.
|
||||
func replicaState(l *Lang, s string) string {
|
||||
if slices.Contains(replicaStates, s) {
|
||||
return l.T("mirror.replica." + s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// restartState is where an operator's restart stands. A refusal carries
|
||||
// the control plane's own reason, which is shown as it came.
|
||||
func restartState(l *Lang, s string) string {
|
||||
switch s {
|
||||
case "queued":
|
||||
return l.T("mirror.restart.queued")
|
||||
case "replacing":
|
||||
return l.T("mirror.restart.replacing")
|
||||
case "in place":
|
||||
return l.T("mirror.restart.in_place")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// roomText says how much more of a model fits, and how much more it could
|
||||
// have by stopping what its class outranks.
|
||||
func roomText(l *Lang, h adminapi.HeadroomView) (room, preempt string) {
|
||||
switch {
|
||||
case h.Limited:
|
||||
room = l.T("mirror.room.at_least", h.More)
|
||||
case h.More > 0 && len(h.Nodes) > 0:
|
||||
var on []string
|
||||
for _, n := range h.Nodes {
|
||||
if len(n) == 0 {
|
||||
on = append(on, "—")
|
||||
continue
|
||||
}
|
||||
on = append(on, strings.Join(n, ", "))
|
||||
}
|
||||
room = l.T("mirror.room.more_on", h.More, strings.Join(on, ", "))
|
||||
case h.More > 0:
|
||||
room = l.T("mirror.room.more", h.More)
|
||||
default:
|
||||
room = l.T("mirror.room.none")
|
||||
}
|
||||
if h.ByPreempting > 0 {
|
||||
who := "—"
|
||||
if len(h.Preempts) > 0 {
|
||||
who = strings.Join(h.Preempts, ", ")
|
||||
}
|
||||
if h.More > 0 {
|
||||
preempt = l.T("mirror.room.and_preempting", h.ByPreempting, who)
|
||||
} else {
|
||||
preempt = l.T("mirror.room.preempting", h.ByPreempting, who)
|
||||
}
|
||||
}
|
||||
return room, preempt
|
||||
}
|
||||
|
||||
// setReplicas changes how many replicas a model always runs and how far it
|
||||
// may grow, refusing an always-on count the cluster has no room for.
|
||||
//
|
||||
@@ -135,49 +209,49 @@ func (p *AccountPages) heldDetailFor(ctx context.Context, row mirrorRow) *heldDe
|
||||
// may have made room, and refusing it would make the ceiling depend on what
|
||||
// happened to be running when it was set.
|
||||
func (p *AccountPages) setReplicas(ctx context.Context, admin store.User, r *http.Request) (string, error) {
|
||||
l := langOf(r)
|
||||
name := r.FormValue("model")
|
||||
lo, err1 := strconv.Atoi(strings.TrimSpace(r.FormValue("min")))
|
||||
hi, err2 := strconv.Atoi(strings.TrimSpace(r.FormValue("max")))
|
||||
switch {
|
||||
case name == "":
|
||||
return "", errors.New("which model?")
|
||||
return "", errors.New(l.T("mirror.err.which_model"))
|
||||
case err1 != nil || err2 != nil:
|
||||
return "", errors.New("replicas are whole numbers")
|
||||
return "", errors.New(l.T("mirror.err.replicas_whole"))
|
||||
case lo < 0 || hi < lo:
|
||||
return "", fmt.Errorf("always running (%d) cannot be more than the most under load (%d)", lo, hi)
|
||||
return "", errors.New(l.T("mirror.err.min_over_max", lo, hi))
|
||||
case hi > maxReplicas:
|
||||
return "", fmt.Errorf("at most %d replicas", maxReplicas)
|
||||
return "", errors.New(l.T("mirror.err.at_most", maxReplicas))
|
||||
}
|
||||
current, err := p.currentModel(ctx, name)
|
||||
current, err := p.currentModel(ctx, l, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if p.catalog.Declared(name) {
|
||||
return "", fmt.Errorf("%s is set in server.yaml; change it there", name)
|
||||
return "", errors.New(l.T("mirror.err.declared", name))
|
||||
}
|
||||
|
||||
note := ""
|
||||
if hs, ok := p.fleet.(HeadroomSource); ok && hi > 0 {
|
||||
h, ok := hs.Headroom(name, hi)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no model %q", name)
|
||||
return "", errors.New(l.T("mirror.err.no_model", name))
|
||||
}
|
||||
fits := h.Current + h.More + h.ByPreempting
|
||||
if lo > fits && !h.Limited {
|
||||
why := "nothing more fits"
|
||||
why := l.T("mirror.err.nothing_fits")
|
||||
if len(h.Blocked) > 0 {
|
||||
why = strings.Join(h.Blocked, "; ")
|
||||
}
|
||||
return "", fmt.Errorf("%s can always run at most %d now (%d running, room for %d more): %s",
|
||||
name, fits, h.Current, h.More, why)
|
||||
return "", errors.New(l.T("mirror.err.no_room", name, fits, h.Current, h.More, why))
|
||||
}
|
||||
if hi > fits && !h.Limited {
|
||||
note = fmt.Sprintf("; there is room for %d now, so it grows past that only if room is made", fits)
|
||||
note = "; " + l.T("mirror.msg.room_for", fits)
|
||||
}
|
||||
// Room its class would make by stopping others: said, since it is
|
||||
// somebody else's replica that goes.
|
||||
if lo > h.Current+h.More && len(h.Preempts) > 0 {
|
||||
note += "; to make room, replicas of " + strings.Join(h.Preempts, ", ") + " will be stopped"
|
||||
note += "; " + l.T("mirror.msg.will_stop", strings.Join(h.Preempts, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,19 +262,21 @@ func (p *AccountPages) setReplicas(ctx context.Context, admin store.User, r *htt
|
||||
if _, err := p.catalog.Scale(ctx, name, sc, admin.Email); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Each says the model's name first and the rest after it, which setModel
|
||||
// relies on to join this onto what else it changed.
|
||||
switch {
|
||||
case hi == 0:
|
||||
return name + " is stopped; its replicas are being taken down", nil
|
||||
return l.T("mirror.msg.stopped", name), nil
|
||||
case lo == 0:
|
||||
return fmt.Sprintf("%s loads when asked for, up to %d under load%s", name, hi, note), nil
|
||||
return l.T("mirror.msg.loads_up_to", name, hi) + note, nil
|
||||
case lo == hi:
|
||||
return fmt.Sprintf("%s runs %s%s", name, plural(lo, "replica", "replicas"), note), nil
|
||||
return l.T("mirror.msg.runs_n", name, l.N("serve.replicas_n", int64(lo))) + note, nil
|
||||
}
|
||||
return fmt.Sprintf("%s runs %d, and up to %d under load%s", name, lo, hi, note), nil
|
||||
return l.T("mirror.msg.runs_up_to", name, lo, hi) + note, nil
|
||||
}
|
||||
|
||||
// currentModel is a model as the catalog holds it now.
|
||||
func (p *AccountPages) currentModel(ctx context.Context, name string) (catalog.Model, error) {
|
||||
func (p *AccountPages) currentModel(ctx context.Context, l *Lang, name string) (catalog.Model, error) {
|
||||
sources, err := p.catalog.Sources(ctx)
|
||||
if err != nil {
|
||||
return catalog.Model{}, err
|
||||
@@ -212,48 +288,38 @@ func (p *AccountPages) currentModel(ctx context.Context, name string) (catalog.M
|
||||
}
|
||||
}
|
||||
}
|
||||
return catalog.Model{}, fmt.Errorf("no model %q runs from the mirror", name)
|
||||
return catalog.Model{}, errors.New(l.T("mirror.err.not_from_mirror", name))
|
||||
}
|
||||
|
||||
// age renders seconds the way the replicas table does.
|
||||
func age(sec int64) string {
|
||||
d := time.Duration(sec) * time.Second
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", sec)
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
func age(sec int64) string { return english.Age(sec) }
|
||||
|
||||
// setModel applies the card's settings form: what a replica reserves, the
|
||||
// model's class and owner, then how many replicas it runs — in that order, so
|
||||
// the room checked for the replicas is the room its new class and reservation
|
||||
// would get. Only what changed is applied, and each change is said.
|
||||
func (p *AccountPages) setModel(ctx context.Context, admin store.User, r *http.Request) (string, error) {
|
||||
l := langOf(r)
|
||||
name := r.FormValue("model")
|
||||
current, err := p.currentModel(ctx, name)
|
||||
current, err := p.currentModel(ctx, l, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if p.catalog.Declared(name) {
|
||||
return "", fmt.Errorf("%s is set in server.yaml; change it there", name)
|
||||
return "", errors.New(l.T("mirror.err.declared", name))
|
||||
}
|
||||
var done []string
|
||||
if r.Form.Has("memory") && len(current.Profiles) > 0 {
|
||||
mem, err1 := strconv.Atoi(strings.TrimSpace(r.FormValue("memory")))
|
||||
cores, err2 := strconv.Atoi(strings.TrimSpace(r.FormValue("cores")))
|
||||
if err1 != nil || err2 != nil || mem < 0 || cores < 0 {
|
||||
return "", errors.New("memory and cores are whole numbers")
|
||||
return "", errors.New(l.T("mirror.err.memory_whole"))
|
||||
}
|
||||
if cur := current.Profiles[0]; mem != cur.MinMemoryGB || cores != cur.CPUCores {
|
||||
if _, err := p.catalog.Resources(ctx, name, "", mem, cores, admin.Email); err != nil {
|
||||
return "", err
|
||||
}
|
||||
done = append(done, fmt.Sprintf("a replica reserves %d GB and %d cores (running ones keep theirs until restarted)", mem, cores))
|
||||
done = append(done, l.T("mirror.msg.reserves", mem, cores))
|
||||
}
|
||||
}
|
||||
if r.Form.Has("class") {
|
||||
@@ -262,7 +328,7 @@ func (p *AccountPages) setModel(ctx context.Context, admin store.User, r *http.R
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
done = append(done, "is "+m.EffectiveClass())
|
||||
done = append(done, l.T("mirror.msg.is_class", l.T("mirror.class."+m.EffectiveClass())))
|
||||
}
|
||||
}
|
||||
if r.Form.Has("owner") && p.capacity != nil {
|
||||
@@ -271,9 +337,9 @@ func (p *AccountPages) setModel(ctx context.Context, admin store.User, r *http.R
|
||||
return "", err
|
||||
}
|
||||
if o == "" {
|
||||
done = append(done, "is shared")
|
||||
done = append(done, l.T("mirror.msg.is_shared"))
|
||||
} else {
|
||||
done = append(done, "counts against "+o)
|
||||
done = append(done, l.T("mirror.msg.counts_against", o))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,16 +349,16 @@ func (p *AccountPages) setModel(ctx context.Context, admin store.User, r *http.R
|
||||
msg, err := p.setReplicas(ctx, admin, r)
|
||||
if err != nil {
|
||||
if len(done) > 0 {
|
||||
return "", fmt.Errorf("%s %s, but not the replicas: %w", name, strings.Join(done, ", "), err)
|
||||
return "", errors.New(l.T("mirror.err.partly_set", name, strings.Join(done, ", "), l.Err(err)))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
done = append(done, strings.TrimPrefix(msg, name+" "))
|
||||
}
|
||||
if len(done) == 0 {
|
||||
return name + ": nothing changed", nil
|
||||
return l.T("mirror.msg.nothing_changed", name), nil
|
||||
}
|
||||
return name + " " + strings.Join(done, "; "), nil
|
||||
return l.T("mirror.msg.changed", name, strings.Join(done, "; ")), nil
|
||||
}
|
||||
|
||||
// heldKinds reads what each held single-file GGUF is — an adapter or a model —
|
||||
@@ -382,8 +448,9 @@ func findAdaptersHref(source string) template.URL {
|
||||
// An adapter is checked before it is attached: a file that is not an adapter,
|
||||
// or one made for another architecture, is a replica that fails to start.
|
||||
func (p *AccountPages) setAdapters(ctx context.Context, admin store.User, r *http.Request) (string, error) {
|
||||
l := langOf(r)
|
||||
name := r.FormValue("model")
|
||||
current, err := p.currentModel(ctx, name)
|
||||
current, err := p.currentModel(ctx, l, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -393,41 +460,39 @@ func (p *AccountPages) setAdapters(ctx context.Context, admin store.User, r *htt
|
||||
gone := r.FormValue("name")
|
||||
adapters = slices.DeleteFunc(adapters, func(a catalog.Adapter) bool { return a.Name == gone })
|
||||
if len(adapters) == len(current.Adapters) {
|
||||
return "", fmt.Errorf("%s has no adapter %q", name, gone)
|
||||
return "", errors.New(l.T("mirror.err.no_adapter", name, gone))
|
||||
}
|
||||
if _, err := p.catalog.SetAdapters(ctx, name, adapters, admin.Email); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s: adapter %s removed; replicas drop it when restarted (llmgw ctl restart --model %s)",
|
||||
name, gone, name), nil
|
||||
return l.T("mirror.msg.adapter_removed", name, gone), nil
|
||||
case "add":
|
||||
an := strings.TrimSpace(r.FormValue("name"))
|
||||
src := strings.TrimSpace(r.FormValue("source"))
|
||||
scale := 1.0
|
||||
if v := strings.TrimSpace(r.FormValue("scale")); v != "" {
|
||||
if scale, err = strconv.ParseFloat(v, 64); err != nil || scale <= 0 {
|
||||
return "", errors.New("the scale is a positive number")
|
||||
return "", errors.New(l.T("mirror.err.scale"))
|
||||
}
|
||||
}
|
||||
if an == "" || src == "" {
|
||||
return "", errors.New("an adapter needs a name and a file")
|
||||
return "", errors.New(l.T("mirror.err.adapter_name_file"))
|
||||
}
|
||||
h, err := kindOf(ctx, src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the adapter file could not be read: %w", err)
|
||||
return "", errors.New(l.T("mirror.err.adapter_unreadable", l.Err(err)))
|
||||
}
|
||||
if !h.IsAdapter() {
|
||||
return "", errors.New("that file is a model, not a LoRA adapter")
|
||||
return "", errors.New(l.T("mirror.err.not_adapter"))
|
||||
}
|
||||
if base, err := kindOf(ctx, current.Source); err == nil && base.Architecture != h.Architecture {
|
||||
return "", fmt.Errorf("that adapter is for %s models and %s is %s", h.Architecture, name, base.Architecture)
|
||||
return "", errors.New(l.T("mirror.err.wrong_arch", h.Architecture, name, base.Architecture))
|
||||
}
|
||||
adapters = append(adapters, catalog.Adapter{Name: an, Source: src, Scale: scale})
|
||||
if _, err := p.catalog.SetAdapters(ctx, name, adapters, admin.Email); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s: adapter %s added; ask for it by that name once the replicas are restarted "+
|
||||
"(llmgw ctl restart --model %s)", name, an, name), nil
|
||||
return l.T("mirror.msg.adapter_added", name, an), nil
|
||||
}
|
||||
return "", errors.New("add or remove an adapter")
|
||||
return "", errors.New(l.T("mirror.err.adapter_op"))
|
||||
}
|
||||
@@ -32,20 +32,29 @@ const minParams = 50e6
|
||||
// exploreShown is how many rows the explorer lists.
|
||||
const exploreShown = 25
|
||||
|
||||
var exploreViews = []struct{ key, label string }{
|
||||
{viewTrending, "Trending"}, {viewNew, "New this month"}, {viewDownloads, "Most downloaded"},
|
||||
{viewAdapters, "LoRA adapters"},
|
||||
// The labels are catalogue keys: explore.view.<key>, explore.task.<key>.
|
||||
var exploreViews = []string{viewTrending, viewNew, viewDownloads, viewAdapters}
|
||||
|
||||
var exploreTasks = []struct{ key, tag string }{
|
||||
{"chat", "text-generation"}, {"embed", "feature-extraction"}, {"any", ""},
|
||||
}
|
||||
|
||||
var exploreTasks = []struct{ key, label, tag string }{
|
||||
{"chat", "Chat", "text-generation"}, {"embed", "Embeddings", "feature-extraction"}, {"any", "Any task", ""},
|
||||
func init() {
|
||||
for _, v := range exploreViews {
|
||||
registerKeys("explore.view." + v)
|
||||
}
|
||||
for _, t := range exploreTasks {
|
||||
registerKeys("explore.task." + t.key)
|
||||
}
|
||||
registerKeys("explore.size.any")
|
||||
}
|
||||
|
||||
var exploreSizes = []struct {
|
||||
key, label string
|
||||
max int64
|
||||
}{
|
||||
{"3", "≤ 3B", 3e9}, {"9", "≤ 9B", 9e9}, {"35", "≤ 35B", 35e9}, {"any", "Any size", 0},
|
||||
// "any" is named in words, explore.size.any; the others are figures.
|
||||
{"3", "≤ 3B", 3e9}, {"9", "≤ 9B", 9e9}, {"35", "≤ 35B", 35e9}, {"any", "", 0},
|
||||
}
|
||||
|
||||
// explorer is the registry half of the mirror page when no repository is
|
||||
@@ -77,6 +86,8 @@ type exploreRow struct {
|
||||
Verdict gguf.Verdict
|
||||
Href template.URL
|
||||
New bool
|
||||
// Updated is how long ago the repository changed, in the page's language.
|
||||
Updated string
|
||||
}
|
||||
|
||||
// ParamsLabel is the parameter count as people say it: 7.6B, 494M.
|
||||
@@ -100,8 +111,8 @@ func (r exploreRow) ContextLabel() string {
|
||||
return fmt.Sprintf("%dk", r.GGUF.ContextLength/1024)
|
||||
}
|
||||
|
||||
// Age is how long ago the repository last changed, coarsely.
|
||||
func (r exploreRow) Age() string {
|
||||
// age is how long ago the repository last changed, coarsely.
|
||||
func (r exploreRow) age(l *Lang) string {
|
||||
t := r.LastModified
|
||||
if t.IsZero() {
|
||||
t = r.CreatedAt
|
||||
@@ -112,15 +123,15 @@ func (r exploreRow) Age() string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Hour:
|
||||
return "just now"
|
||||
return l.T("explore.age.now")
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||
return l.T("explore.age.h", int(d.Hours()))
|
||||
case d < 14*24*time.Hour:
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
return l.T("explore.age.d", int(d.Hours()/24))
|
||||
case d < 60*24*time.Hour:
|
||||
return fmt.Sprintf("%dw", int(d.Hours()/24/7))
|
||||
return l.T("explore.age.w", int(d.Hours()/24/7))
|
||||
default:
|
||||
return fmt.Sprintf("%dmo", int(d.Hours()/24/30))
|
||||
return l.T("explore.age.mo", int(d.Hours()/24/30))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,20 +240,25 @@ func (p *AccountPages) exploreList(r *http.Request, d *adminMirrorData) {
|
||||
e.Of = repo.BaseModel[0]
|
||||
}
|
||||
}
|
||||
l := langOf(r)
|
||||
for _, v := range exploreViews {
|
||||
e.Views = append(e.Views, exploreLink{v.label, e.href(d.Query, "view", v.key), v.key == e.View})
|
||||
e.Views = append(e.Views, exploreLink{l.T("explore.view." + v), e.href(d.Query, "view", v), v == e.View})
|
||||
}
|
||||
for _, t := range exploreTasks {
|
||||
e.Tasks = append(e.Tasks, exploreLink{t.label, e.href(d.Query, "task", t.key), t.key == e.Task})
|
||||
e.Tasks = append(e.Tasks, exploreLink{l.T("explore.task." + t.key), e.href(d.Query, "task", t.key), t.key == e.Task})
|
||||
}
|
||||
for _, s := range exploreSizes {
|
||||
e.Sizes = append(e.Sizes, exploreLink{s.label, e.href(d.Query, "size", s.key), s.key == e.Size})
|
||||
label := s.label
|
||||
if label == "" {
|
||||
label = l.T("explore.size." + s.key)
|
||||
}
|
||||
e.Sizes = append(e.Sizes, exploreLink{label, e.href(d.Query, "size", s.key), s.key == e.Size})
|
||||
}
|
||||
d.Explore = &e
|
||||
|
||||
got, err := p.registry.Explore(r.Context(), e.hfQuery(d.Query))
|
||||
if err != nil {
|
||||
d.RegistryErr = err.Error()
|
||||
d.RegistryErr = l.Err(err)
|
||||
return
|
||||
}
|
||||
back := e.href(d.Query, "", "")
|
||||
@@ -253,6 +269,7 @@ func (p *AccountPages) exploreList(r *http.Request, d *adminMirrorData) {
|
||||
row := exploreRow{Model: m, Verdict: gguf.Verdict{Servable: true},
|
||||
Href: back + template.URL("&repo="+url.QueryEscape(m.ID)),
|
||||
New: !m.CreatedAt.IsZero() && time.Since(m.CreatedAt) < newWithin}
|
||||
row.Updated = row.age(l)
|
||||
if a := m.Architecture(); a != "" && p.llamaVersion != "" {
|
||||
row.Verdict = gguf.LlamaCpp(p.llamaVersion, a)
|
||||
}
|
||||
|
||||
@@ -60,11 +60,13 @@ func NodeLogsPage(src Source, onAdminListener bool, version string) http.Handler
|
||||
}
|
||||
ls, ok := src.(LogSource)
|
||||
if !ok {
|
||||
d.LogsView = adminapi.LogsView{Node: node, Error: "this control plane cannot read node logs"}
|
||||
d.LogsView = adminapi.LogsView{Node: node, Error: langOf(r).T("logs.cannot_read")}
|
||||
} else {
|
||||
// An error the control plane gives back is shown as it came, in
|
||||
// English.
|
||||
d.LogsView = ls.NodeLogs(r.Context(), node, r.URL.Query().Get("source"), d.Count)
|
||||
}
|
||||
noStore(w)
|
||||
render(w, "node_logs", d)
|
||||
render(w, r, "node_logs", d)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
@@ -26,6 +27,8 @@ type nodePageData struct {
|
||||
Self string
|
||||
Version, Now string
|
||||
RefreshSec int
|
||||
// RefreshMeta is the reload for a browser without script.
|
||||
RefreshMeta template.HTML
|
||||
}
|
||||
|
||||
// nodeEngine is one engine running on the node, and its share of it.
|
||||
@@ -96,9 +99,10 @@ func NodePage(src Source, history History, window time.Duration, onAdminListener
|
||||
d := nodePageData{
|
||||
Nav: navFrom(r, "cluster"), OnAdminListener: onAdminListener,
|
||||
Back: "/admin", Logs: "/admin/nodes/" + id + "/logs", Self: "/admin/nodes/" + id,
|
||||
HasHistory: history != nil, Version: version, RefreshSec: 15,
|
||||
HasHistory: history != nil, Version: version, RefreshSec: 15, RefreshMeta: refreshMeta(15),
|
||||
Now: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
l := langOf(r)
|
||||
if onAdminListener {
|
||||
d.Back, d.Logs, d.Self = "/", "/nodes/"+id+"/logs", "/nodes/"+id
|
||||
}
|
||||
@@ -106,6 +110,7 @@ func NodePage(src Source, history History, window time.Duration, onAdminListener
|
||||
for _, n := range src.Nodes() {
|
||||
if n.ID == id {
|
||||
d.Node, found = n, true
|
||||
d.Node.LastSeenAgo = l.clockText(n.LastSeenAgo)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -134,18 +139,18 @@ func NodePage(src Source, history History, window time.Duration, onAdminListener
|
||||
sort.Slice(d.Engines, func(i, j int) bool { return d.Engines[i].CPUCores > d.Engines[j].CPUCores })
|
||||
|
||||
if history != nil {
|
||||
d.Charts = loadNodeCharts(r.Context(), history, id, replicas, d.Node.GPUTotal, window, log)
|
||||
d.Charts = loadNodeCharts(r.Context(), l, history, id, replicas, d.Node.GPUTotal, window, log)
|
||||
}
|
||||
noStore(w)
|
||||
render(w, "node", d)
|
||||
render(w, r, "node", d)
|
||||
}
|
||||
}
|
||||
|
||||
func loadNodeCharts(ctx context.Context, h History, node string, replicas []string, gpus int,
|
||||
func loadNodeCharts(ctx context.Context, l *Lang, h History, node string, replicas []string, gpus int,
|
||||
window time.Duration, log *slog.Logger,
|
||||
) *nodeCharts {
|
||||
step := window / 60
|
||||
out := &nodeCharts{Window: windowWord(window)}
|
||||
out := &nodeCharts{Window: l.windowWord(window)}
|
||||
quoted := fmt.Sprintf("%q", node)
|
||||
for _, p := range nodePanels {
|
||||
series, err := h.RangeQuery(ctx, fmt.Sprintf(p.expr, quoted), p.nameBy, window, step)
|
||||
@@ -156,7 +161,7 @@ func loadNodeCharts(ctx context.Context, h History, node string, replicas []stri
|
||||
out.Err = err.Error()
|
||||
return out
|
||||
}
|
||||
p.into(out, LineChart(series, p.unit, 4))
|
||||
p.into(out, l.LineChart(series, p.unit, 4))
|
||||
}
|
||||
// Every card its own line, up to the eight a node commonly has.
|
||||
if gpus > 0 {
|
||||
@@ -168,7 +173,7 @@ func loadNodeCharts(ctx context.Context, h History, node string, replicas []stri
|
||||
for i := range series {
|
||||
series[i].Name = "GPU " + series[i].Name
|
||||
}
|
||||
p.into(out, LineChart(series, p.unit, max(gpus, 8)))
|
||||
p.into(out, l.LineChart(series, p.unit, max(gpus, 8)))
|
||||
}
|
||||
}
|
||||
// The engines' queues, for the replicas that are here now.
|
||||
@@ -179,7 +184,7 @@ func loadNodeCharts(ctx context.Context, h History, node string, replicas []stri
|
||||
}
|
||||
expr := fmt.Sprintf(`llmgw_endpoint_waiting{replica=~%q}`, strings.Join(re, "|"))
|
||||
if series, err := h.RangeQuery(ctx, expr, "replica", window, step); err == nil {
|
||||
out.Queue = LineChart(series, "", 4)
|
||||
out.Queue = l.LineChart(series, "", 4)
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -51,6 +51,6 @@ func NVLinkPage(src Source, onAdminListener bool, version string) http.HandlerFu
|
||||
d.Domains = append(d.Domains, v)
|
||||
}
|
||||
noStore(w)
|
||||
render(w, "nvlink", d)
|
||||
render(w, r, "nvlink", d)
|
||||
}
|
||||
}
|
||||
+33
-16
@@ -142,8 +142,8 @@ func (p *AccountPages) oidcStart(w http.ResponseWriter, r *http.Request) {
|
||||
pr, err := o.providerFor(r.Context())
|
||||
if err != nil {
|
||||
p.log.Warn("the identity provider did not answer", "issuer", o.cfg.Issuer, "err", err)
|
||||
renderStatus(w, http.StatusBadGateway, "login", loginData{SignupOpen: p.accounts.SignupOpen(), Version: p.version,
|
||||
CSRF: setLoginToken(w, r), SSO: o.cfg.Label, Error: "the sign-in service did not answer; try again shortly"})
|
||||
renderStatus(w, r, http.StatusBadGateway, "login", loginData{SignupOpen: p.accounts.SignupOpen(), Version: p.version,
|
||||
CSRF: setLoginToken(w, r), SSO: o.cfg.Label, Error: langOf(r).T("sso.err.no_answer")})
|
||||
return
|
||||
}
|
||||
state, nonce, verifier := randomToken(), randomToken(), oauth2.GenerateVerifier()
|
||||
@@ -154,20 +154,19 @@ func (p *AccountPages) oidcStart(w http.ResponseWriter, r *http.Request) {
|
||||
http.StatusFound)
|
||||
}
|
||||
|
||||
// errOIDC is a sign-in through the provider that did not work, said plainly.
|
||||
var errOIDC = errors.New("signing in through the provider did not work; start again")
|
||||
|
||||
func (p *AccountPages) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
||||
noStore(w)
|
||||
o := p.oidc
|
||||
fail := func(why string, err error) {
|
||||
p.log.Warn("an OIDC sign-in was refused", "why", why, "err", err)
|
||||
http.SetCookie(w, &http.Cookie{Name: oidcCookie, Path: "/login/oidc", MaxAge: -1})
|
||||
msg := errOIDC.Error()
|
||||
if errors.Is(err, errOIDCAccount) {
|
||||
msg = err.Error()
|
||||
l := langOf(r)
|
||||
msg := l.T("sso.err.failed")
|
||||
var ae accountError
|
||||
if errors.As(err, &ae) {
|
||||
msg = ae.in(l)
|
||||
}
|
||||
renderStatus(w, http.StatusForbidden, "login", loginData{SignupOpen: p.accounts.SignupOpen(),
|
||||
renderStatus(w, r, http.StatusForbidden, "login", loginData{SignupOpen: p.accounts.SignupOpen(),
|
||||
Version: p.version, CSRF: setLoginToken(w, r), SSO: o.cfg.Label, Error: msg})
|
||||
}
|
||||
c, err := r.Cookie(oidcCookie)
|
||||
@@ -233,10 +232,22 @@ func (p *AccountPages) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
||||
// errOIDCAccount is a provider's person the gateway will not let in, with why.
|
||||
var errOIDCAccount = errors.New("")
|
||||
|
||||
type accountError string
|
||||
// accountError is the sentence for the person, by its key and the address it
|
||||
// names, so the page can say it in their language; Error is the English, for
|
||||
// the log.
|
||||
type accountError struct {
|
||||
key string
|
||||
args []any
|
||||
}
|
||||
|
||||
func (e accountError) Error() string { return string(e) }
|
||||
func (e accountError) Is(t error) bool { return t == errOIDCAccount }
|
||||
func (e accountError) Error() string { return english.T(e.key, e.args...) }
|
||||
func (e accountError) Is(t error) bool { return t == errOIDCAccount }
|
||||
func (e accountError) in(l *Lang) string { return l.T(e.key, e.args...) }
|
||||
|
||||
func init() {
|
||||
registerKeys("sso.err.no_email", "sso.err.unverified", "sso.err.no_account",
|
||||
"sso.err.pending", "sso.err.suspended", "sso.err.state")
|
||||
}
|
||||
|
||||
// oidcUser is the account a provider's person signs in as: found by their
|
||||
// verified email, made when the gateway makes accounts this way, with the
|
||||
@@ -246,10 +257,10 @@ func (p *AccountPages) oidcUser(ctx context.Context, claims map[string]any) (sto
|
||||
email, _ := claims["email"].(string)
|
||||
email = store.NormalizeEmail(email)
|
||||
if email == "" {
|
||||
return store.User{}, accountError("the provider gave no email address; ask it to send the email claim")
|
||||
return store.User{}, accountError{key: "sso.err.no_email"}
|
||||
}
|
||||
if v, ok := claims["email_verified"].(bool); ok && !v {
|
||||
return store.User{}, accountError("the provider has not verified " + email)
|
||||
return store.User{}, accountError{"sso.err.unverified", []any{email}}
|
||||
}
|
||||
u, err := o.st.UserByEmail(ctx, email)
|
||||
switch {
|
||||
@@ -266,12 +277,18 @@ func (p *AccountPages) oidcUser(ctx context.Context, claims map[string]any) (sto
|
||||
}
|
||||
p.log.Info("an account was made for a person the identity provider vouched for", "email", email)
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
return store.User{}, accountError("there is no account for " + email + " on this gateway; ask an administrator for one")
|
||||
return store.User{}, accountError{"sso.err.no_account", []any{email}}
|
||||
case err != nil:
|
||||
return store.User{}, err
|
||||
}
|
||||
if !u.CanSignIn() {
|
||||
return store.User{}, accountError("the account for " + email + " is " + string(u.State))
|
||||
switch u.State {
|
||||
case store.UserPending:
|
||||
return store.User{}, accountError{"sso.err.pending", []any{email}}
|
||||
case store.UserSuspended:
|
||||
return store.User{}, accountError{"sso.err.suspended", []any{email}}
|
||||
}
|
||||
return store.User{}, accountError{"sso.err.state", []any{email, string(u.State)}}
|
||||
}
|
||||
|
||||
groups := claimStrings(claims[or(o.cfg.GroupsClaim, "groups")])
|
||||
|
||||
+17
-19
@@ -34,16 +34,18 @@ type orgMember struct {
|
||||
|
||||
type orgPlace struct{ Where, Role string }
|
||||
|
||||
// A member's state is a word the page shows and a class it styles by.
|
||||
func init() {
|
||||
registerKeys("org.state.pending", "org.state.active", "org.state.suspended")
|
||||
}
|
||||
|
||||
type orgPageData struct {
|
||||
Nav Nav
|
||||
Org store.Org
|
||||
Orgs []store.Org // the others this person may look at
|
||||
|
||||
Groups []usageGroup
|
||||
Windows []struct {
|
||||
Key, Label string
|
||||
Selected bool
|
||||
}
|
||||
Groups []usageGroup
|
||||
Windows []windowChoice
|
||||
By, Window string
|
||||
|
||||
Requests TimeSeries
|
||||
@@ -79,7 +81,7 @@ func (p *AccountPages) RegisterOrgPages(mux *http.ServeMux, s OrgViewer, priced
|
||||
// Said, not a 404: an administrator following the link on a
|
||||
// cluster with no organizations should learn that, and where to
|
||||
// make one.
|
||||
render(w, "org-none", struct {
|
||||
render(w, r, "org-none", struct {
|
||||
Nav Nav
|
||||
Version, Now string
|
||||
}{navFrom(r, "org"), p.version, time.Now().UTC().Format(time.RFC3339)})
|
||||
@@ -155,6 +157,7 @@ func (p *AccountPages) renderOrg(w http.ResponseWriter, r *http.Request, s OrgVi
|
||||
by = store.GroupByTeam
|
||||
}
|
||||
win := windowFor(r.URL.Query().Get("window"))
|
||||
l := langOf(r)
|
||||
d := orgPageData{
|
||||
Nav: navFrom(r, "org"),
|
||||
By: string(by), Window: win.Key,
|
||||
@@ -163,20 +166,15 @@ func (p *AccountPages) renderOrg(w http.ResponseWriter, r *http.Request, s OrgVi
|
||||
Now: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
for _, g := range []store.Grouping{store.GroupByTeam, store.GroupByProject, store.GroupByUser} {
|
||||
d.Groups = append(d.Groups, usageGroup{Key: string(g), Label: groupLabel(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.Groups = append(d.Groups, usageGroup{Key: string(g), Label: groupLabel(l, g), Selected: g == by})
|
||||
}
|
||||
d.Windows = windowChoices(l, win.Key)
|
||||
|
||||
d.Orgs, _ = p.viewableOrgs(ctx, s, user)
|
||||
tree, err := s.Tree(ctx)
|
||||
if err != nil {
|
||||
p.log.Error("could not read the tree", "err", err)
|
||||
d.Error = "could not read the organization"
|
||||
d.Error = l.T("org.err.read_tree")
|
||||
}
|
||||
var mine []store.TreeOrg
|
||||
inOrg := map[string]string{} // scope:id -> path within the org
|
||||
@@ -186,7 +184,7 @@ func (p *AccountPages) renderOrg(w http.ResponseWriter, r *http.Request, s OrgVi
|
||||
}
|
||||
d.Org = store.Org{ID: o.ID, Name: o.Name}
|
||||
mine = []store.TreeOrg{o}
|
||||
inOrg["org:"+o.ID] = "the whole organization"
|
||||
inOrg["org:"+o.ID] = l.T("org.whole")
|
||||
for _, t := range o.Teams {
|
||||
inOrg["team:"+t.ID] = t.Name
|
||||
for _, pr := range t.Projects {
|
||||
@@ -201,7 +199,7 @@ func (p *AccountPages) renderOrg(w http.ResponseWriter, r *http.Request, s OrgVi
|
||||
|
||||
const quotaWindow = 30 * 24 * time.Hour
|
||||
if limits, err := s.AllLimits(ctx); err == nil {
|
||||
d.Quotas = quotaRows(mine, limits, p.spendByScope(ctx, s, time.Now().Add(-quotaWindow)))
|
||||
d.Quotas = quotaRowsIn(l, mine, limits, p.spendByScope(ctx, s, time.Now().Add(-quotaWindow)))
|
||||
}
|
||||
|
||||
users, _ := s.Users(ctx, "")
|
||||
@@ -233,8 +231,8 @@ func (p *AccountPages) renderOrg(w http.ResponseWriter, r *http.Request, s OrgVi
|
||||
rows, err := s.UsageBucketsIn(ctx, by, orgID, time.Now().Add(-win.Span), win.Width)
|
||||
if err != nil {
|
||||
p.log.Error("could not read an organization's usage", "org", orgID, "err", err)
|
||||
d.Error = "could not read usage"
|
||||
d.Error = l.T("usage.err.read")
|
||||
}
|
||||
d.Requests, d.Tokens, d.Spend, d.Rows = usageCharts(rows)
|
||||
render(w, "org", d)
|
||||
d.Requests, d.Tokens, d.Spend, d.Rows = usageChartsIn(l, rows)
|
||||
render(w, r, "org", d)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func TestEachStaffRoleMayDoWhatItSays(t *testing.T) {
|
||||
|
||||
func TestABillingRoleIsShownUsageAndQuotasOnly(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
render(rec, "admin_guard", guardPageData{Nav: Nav{Admin: true, Role: store.RoleBilling}})
|
||||
render(rec, nil, "admin_guard", guardPageData{Nav: Nav{Admin: true, Role: store.RoleBilling}})
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `href="/admin/usage"`) || strings.Contains(body, `href="/admin/users"`) {
|
||||
t.Errorf("menu: %s", body)
|
||||
|
||||
+29
-30
@@ -2,7 +2,6 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
@@ -44,7 +43,7 @@ type placeUse struct {
|
||||
Spend string
|
||||
}
|
||||
|
||||
func (p *AccountPages) fillOwnUse(ctx context.Context, user store.User, d *profileData) {
|
||||
func (p *AccountPages) fillOwnUse(ctx context.Context, l *Lang, user store.User, d *profileData) {
|
||||
su, ok := p.keys.(SelfUsage)
|
||||
if !ok {
|
||||
return
|
||||
@@ -61,16 +60,16 @@ func (p *AccountPages) fillOwnUse(ctx context.Context, user store.User, d *profi
|
||||
u.Requests += pl.Requests
|
||||
u.Tokens += pl.Tokens
|
||||
cost += pl.CostMicros
|
||||
byPlace[pl.ProjectID] = placeUse{pl.Requests, pl.Tokens, units(pl.CostMicros)}
|
||||
byPlace[pl.ProjectID] = placeUse{pl.Requests, pl.Tokens, l.Units(pl.CostMicros)}
|
||||
}
|
||||
u.Spend = units(cost)
|
||||
u.Spend = l.Units(cost)
|
||||
s := Series{Colour: "--accent"}
|
||||
for _, b := range buckets {
|
||||
s.Values = append(s.Values, float64(b.Prompt+b.Completion))
|
||||
s.Labels = append(s.Labels, b.Start.UTC().Format("Jan 2"))
|
||||
s.Labels = append(s.Labels, l.Format(b.Start.UTC(), "Jan 2"))
|
||||
}
|
||||
if u.Requests > 0 {
|
||||
u.Chart = BarChart(s, "tokens")
|
||||
u.Chart = l.BarChart(s, l.T("selfuse.chart_unit"))
|
||||
}
|
||||
d.Own = u
|
||||
|
||||
@@ -79,22 +78,22 @@ func (p *AccountPages) fillOwnUse(ctx context.Context, user store.User, d *profi
|
||||
if pu, ok := byPlace[c.project]; ok {
|
||||
c.Use = &pu
|
||||
}
|
||||
for _, l := range c.chain {
|
||||
if l.Budget == nil || l.Scope == store.ScopeKey {
|
||||
for _, lim := range c.chain {
|
||||
if lim.Budget == nil || lim.Scope == store.ScopeKey {
|
||||
continue
|
||||
}
|
||||
start := accounting.PeriodStart(l.BudgetPeriod, time.Now())
|
||||
spent, err := su.SpendOfPool(ctx, l, start)
|
||||
start := accounting.PeriodStart(lim.BudgetPeriod, time.Now())
|
||||
spent, err := su.SpendOfPool(ctx, lim, start)
|
||||
if err != nil {
|
||||
p.log.Warn("could not read a pool's spend", "scope", l.Scope, "err", err)
|
||||
p.log.Warn("could not read a pool's spend", "scope", lim.Scope, "err", err)
|
||||
continue
|
||||
}
|
||||
bar := budgetBar{Label: poolLabel(l.Scope), Used: units(spent), Budget: units(*l.Budget),
|
||||
Period: periodWord(l.BudgetPeriod)}
|
||||
if *l.Budget > 0 {
|
||||
bar.Percent = int(min(spent*100 / *l.Budget, 100))
|
||||
bar.Near = spent*5 >= *l.Budget*4
|
||||
bar.Over = spent >= *l.Budget
|
||||
bar := budgetBar{Label: poolLabel(l, lim.Scope), Used: l.Units(spent), Budget: l.Units(*lim.Budget),
|
||||
Period: periodWord(l, lim.BudgetPeriod)}
|
||||
if *lim.Budget > 0 {
|
||||
bar.Percent = int(min(spent*100 / *lim.Budget, 100))
|
||||
bar.Near = spent*5 >= *lim.Budget*4
|
||||
bar.Over = spent >= *lim.Budget
|
||||
}
|
||||
c.Budgets = append(c.Budgets, bar)
|
||||
}
|
||||
@@ -104,34 +103,34 @@ func (p *AccountPages) fillOwnUse(ctx context.Context, user store.User, d *profi
|
||||
// poolLabel says whose budget a limit is, which is the thing a person needs
|
||||
// to read a bar right: their own is theirs to spend, a project's is shared
|
||||
// with everyone in it.
|
||||
func poolLabel(s store.Scope) string {
|
||||
func poolLabel(l *Lang, s store.Scope) string {
|
||||
switch s {
|
||||
case store.ScopeUser:
|
||||
return "Your budget, across everything you use"
|
||||
return l.T("selfuse.pool.user")
|
||||
case store.ScopeProjectMember:
|
||||
return "Your budget in this project"
|
||||
return l.T("selfuse.pool.project_member")
|
||||
case store.ScopeTeamMember:
|
||||
return "Your budget in this team"
|
||||
return l.T("selfuse.pool.team_member")
|
||||
case store.ScopeOrgMember:
|
||||
return "Your budget in this organization"
|
||||
return l.T("selfuse.pool.org_member")
|
||||
case store.ScopeProject:
|
||||
return "The project's budget, shared with its members"
|
||||
return l.T("selfuse.pool.project")
|
||||
case store.ScopeTeam:
|
||||
return "The team's budget, shared across its projects"
|
||||
return l.T("selfuse.pool.team")
|
||||
case store.ScopeOrg:
|
||||
return "The organization's budget, shared across it"
|
||||
return l.T("selfuse.pool.org")
|
||||
}
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func periodWord(p store.Period) string {
|
||||
func periodWord(l *Lang, p store.Period) string {
|
||||
switch p {
|
||||
case store.PeriodDay:
|
||||
return "today"
|
||||
return l.T("selfuse.period.day")
|
||||
case store.PeriodMonth:
|
||||
return "this month"
|
||||
return l.T("selfuse.period.month")
|
||||
case store.PeriodNone:
|
||||
return "in total"
|
||||
return l.T("selfuse.period.none")
|
||||
}
|
||||
return fmt.Sprintf("this %s", p)
|
||||
return l.T("selfuse.period.other", string(p))
|
||||
}
|
||||
@@ -902,3 +902,6 @@ a.sel-link.small { font-size: .78rem; color: var(--muted); margin-left: .4rem; }
|
||||
.people-detail h3.chart-title { text-transform: none; letter-spacing: 0; color: var(--fg); font-size: .85rem; margin: .2rem 0; }
|
||||
.people-detail details.edit { display: inline-block; margin-right: 1rem; vertical-align: top; }
|
||||
.people-detail details.edit[open] { display: block; margin: .5rem 0; }
|
||||
/* The language switcher at the foot of every page. */
|
||||
.langs a { color: var(--muted); }
|
||||
.langs b { font-weight: 600; }
|
||||
@@ -24,15 +24,42 @@
|
||||
const panel = root.querySelector(".fm-panel");
|
||||
const stats = root.querySelector(".fm-stats");
|
||||
|
||||
// --- words -------------------------------------------------------------
|
||||
// What the map says comes from the page, in the page's language: the
|
||||
// server writes its catalogue's sentences into #fabric-i18n, with {0}, {1}
|
||||
// where a figure goes. English is kept here for a page that has none, so
|
||||
// the map still speaks rather than printing keys.
|
||||
const EN = {
|
||||
decimal: ".",
|
||||
map_failed: "The map could not be made: {0}",
|
||||
not_mapped: "Not mapped yet: the first scan runs within five minutes of a node on this fabric connecting.",
|
||||
"tier.leaf": "leaf", "tier.spine": "spine", "tier.core": "core", "tier.supercore": "super-core", "tier.n": "tier {0}",
|
||||
adapters_row: "adapters", ports_used: "{0}/{1} ports", hosts: "{0} hosts", cables_n: "{0} cables, ",
|
||||
below_rate: "trained below the {0} Gb/s the rest of its kind run at",
|
||||
adapters_n: "{0} adapters", degraded_n: "{0} degraded cable(s)", rail: "rail {0}", not_ours: "not one of this cluster's nodes",
|
||||
"dt.tier": "tier", "dt.ports": "ports", "dt.adapters": "adapters", "dt.adapter": "adapter", "dt.rail": "rail",
|
||||
"dt.leaf": "leaf", "dt.state": "state", "dt.rate": "rate", "dt.sending": "sending", "dt.receiving": "receiving",
|
||||
cabled: "{0} of {1} cabled", degraded_cables: "Degraded cables", all_good: "Every cable at its expected rate.",
|
||||
port_to: "port {0} → {1}: {2}", close: "close", open_node: "open {0} →", adapter: "adapter",
|
||||
"stat.switches": "switches", "stat.adapters": "adapters", "stat.cables": "cables", "stat.degraded": "degraded",
|
||||
"stat.rails": "rails", "stat.aligned": "aligned", "stat.crossed": "crossed", "stat.sent": "sent now",
|
||||
};
|
||||
const S = (() => {
|
||||
const e = document.getElementById("fabric-i18n");
|
||||
try { return e ? JSON.parse(e.textContent) : {}; } catch (_) { return {}; }
|
||||
})();
|
||||
const T = (k, ...a) => String(S[k] || EN[k] || k).replace(/\{(\d)\}/g, (_, i) => a[+i]);
|
||||
const fixed = (v, n) => v.toFixed(n).replace(".", S.decimal || EN.decimal);
|
||||
|
||||
// --- colours ---------------------------------------------------------
|
||||
const speedClass = (g) => g >= 800 ? "xdr" : g >= 400 ? "ndr" : g >= 200 ? "hdr" : g >= 100 ? "edr" : "slow";
|
||||
const railPalette = ["#38bdf8", "#a78bfa", "#34d399", "#fbbf24", "#f472b6", "#fb7185", "#2dd4bf", "#c084fc"];
|
||||
const fmtBps = (b) => {
|
||||
const bits = b * 8;
|
||||
if (bits >= 1e9) return (bits / 1e9).toFixed(1) + " Gb/s";
|
||||
if (bits >= 1e6) return (bits / 1e6).toFixed(1) + " Mb/s";
|
||||
if (bits >= 1e3) return (bits / 1e3).toFixed(0) + " kb/s";
|
||||
return bits.toFixed(0) + " b/s";
|
||||
if (bits >= 1e9) return fixed(bits / 1e9, 1) + " Gb/s";
|
||||
if (bits >= 1e6) return fixed(bits / 1e6, 1) + " Mb/s";
|
||||
if (bits >= 1e3) return fixed(bits / 1e3, 0) + " kb/s";
|
||||
return fixed(bits, 0) + " b/s";
|
||||
};
|
||||
const el = (name, attrs, parent) => {
|
||||
const e = document.createElementNS(svgNS, name);
|
||||
@@ -124,8 +151,7 @@
|
||||
function draw() {
|
||||
canvas.textContent = "";
|
||||
if (!data || !data.map) {
|
||||
const msg = data && data.error ? "The map could not be made: " + data.error
|
||||
: "Not mapped yet: the first scan runs within five minutes of a node on this fabric connecting.";
|
||||
const msg = data && data.error ? T("map_failed", data.error) : T("not_mapped");
|
||||
canvas.innerHTML = '<p class="fm-empty">' + esc(msg) + "</p>";
|
||||
fillStats();
|
||||
return;
|
||||
@@ -151,13 +177,13 @@
|
||||
viewport = el("g", { class: "fm-viewport" }, svg);
|
||||
|
||||
// Tier labels down the left.
|
||||
const names = { 1: "leaf", 2: "spine", 3: "core", 4: "super-core" };
|
||||
const names = { 1: T("tier.leaf"), 2: T("tier.spine"), 3: T("tier.core"), 4: T("tier.supercore") };
|
||||
// Tier names down the left edge, turned on their side so they take no
|
||||
// room from the leftmost switch.
|
||||
const label = (y, text) => el("text", { class: "fm-tier",
|
||||
transform: `translate(${14 * L.fit},${y}) rotate(-90) scale(${L.fit})` }, viewport).textContent = text;
|
||||
for (let t = L.tiers; t >= 1; t--) label(L.rowY(t), names[t] || "tier " + t);
|
||||
label(L.rowY(1) + L.gap, "adapters");
|
||||
for (let t = L.tiers; t >= 1; t--) label(L.rowY(t), names[t] || T("tier.n", t));
|
||||
label(L.rowY(1) + L.gap, T("adapters_row"));
|
||||
|
||||
// Cables, parallel ones bundled.
|
||||
const bundles = new Map();
|
||||
@@ -210,12 +236,12 @@
|
||||
const nodeLayer = el("g", { class: "fm-nodes" }, viewport);
|
||||
for (const s of map.switches) {
|
||||
const p = L.pos[s.guid];
|
||||
const tierName = s.tier >= 3 ? "core" : s.tier === 2 ? "spine" : "leaf";
|
||||
const g = el("g", { class: "fm-switch t-" + tierName + (touches[s.guid] && touches[s.guid].bad ? " has-bad" : ""),
|
||||
const tierClass = s.tier >= 3 ? "core" : s.tier === 2 ? "spine" : "leaf";
|
||||
const g = el("g", { class: "fm-switch t-" + tierClass + (touches[s.guid] && touches[s.guid].bad ? " has-bad" : ""),
|
||||
transform: `translate(${p.x},${p.y}) scale(${L.swScale})`, tabindex: 0 }, nodeLayer);
|
||||
el("rect", { x: -56, y: -17, width: 112, height: 34, rx: 10, fill: `url(#fm-g-${tierName})` }, g);
|
||||
el("rect", { x: -56, y: -17, width: 112, height: 34, rx: 10, fill: `url(#fm-g-${tierClass})` }, g);
|
||||
el("text", { class: "fm-name", y: -1 }, g).textContent = s.name.length > 16 ? s.name.slice(0, 15) + "…" : s.name;
|
||||
el("text", { class: "fm-sub", y: 11 }, g).textContent = `${s.used}/${s.ports} ports` + (s.hosts ? ` · ${s.hosts} hosts` : "");
|
||||
el("text", { class: "fm-sub", y: 11 }, g).textContent = T("ports_used", s.used, s.ports) + (s.hosts ? " · " + T("hosts", s.hosts) : "");
|
||||
// Port use as an arc along the bottom edge.
|
||||
const frac = s.ports ? Math.min(1, s.used / s.ports) : 0;
|
||||
el("line", { x1: -46, y1: 15.5, x2: -46 + 92 * frac, y2: 15.5, class: "fm-use" }, g);
|
||||
@@ -315,22 +341,24 @@
|
||||
return s ? s.name : h ? (h.node ? h.node + " " + (h.hca || "") : h.desc) : g; };
|
||||
const k = b.links[0];
|
||||
const exp = k.host ? map.expected_host_gbps : map.expected_switch_gbps;
|
||||
return `<b>${esc(n(b.from))} ↔ ${esc(n(b.to))}</b><br>${b.links.length > 1 ? b.links.length + " cables, " : ""}${esc(k.rate)} · ${k.gbps} Gb/s` +
|
||||
(b.links.some((x) => x.degraded) ? `<br><span class="fm-bad">trained below the ${exp} Gb/s the rest of its kind run at</span>` : "");
|
||||
return `<b>${esc(n(b.from))} ↔ ${esc(n(b.to))}</b><br>${b.links.length > 1 ? esc(T("cables_n", b.links.length)) : ""}${esc(k.rate)} · ${k.gbps} Gb/s` +
|
||||
(b.links.some((x) => x.degraded) ? `<br><span class="fm-bad">${esc(T("below_rate", exp))}</span>` : "");
|
||||
}
|
||||
const tierName = (s) => s.tier >= 3 ? T("tier.core") : s.tier === 2 ? T("tier.spine") : T("tier.leaf");
|
||||
function switchTip(s, map, touches) {
|
||||
const bad = touches[s.guid] && touches[s.guid].bad;
|
||||
return `<b>${esc(s.name)}</b><br>${s.tier >= 3 ? "core" : s.tier === 2 ? "spine" : "leaf"} · ${s.used}/${s.ports} ports` +
|
||||
(s.hosts ? ` · ${s.hosts} adapters` : "") + (s.lid ? ` · LID ${s.lid}` : "") + (bad ? `<br><span class="fm-bad">${bad} degraded cable(s)</span>` : "");
|
||||
return `<b>${esc(s.name)}</b><br>${esc(tierName(s))} · ${esc(T("ports_used", s.used, s.ports))}` +
|
||||
(s.hosts ? " · " + esc(T("adapters_n", s.hosts)) : "") + (s.lid ? ` · LID ${s.lid}` : "") +
|
||||
(bad ? `<br><span class="fm-bad">${esc(T("degraded_n", bad))}</span>` : "");
|
||||
}
|
||||
function hostTip(h, live) {
|
||||
let t = `<b>${esc(h.node || h.desc || h.guid)}</b>${h.hca ? " · " + esc(h.hca) : ""}<br>rail ${h.rail}`;
|
||||
let t = `<b>${esc(h.node || h.desc || h.guid)}</b>${h.hca ? " · " + esc(h.hca) : ""}<br>${esc(T("rail", h.rail))}`;
|
||||
if (live) {
|
||||
t += ` · ${esc(live.state)}${live.gbps ? " · " + live.gbps + " Gb/s" : ""}`;
|
||||
if (live.tx_bps || live.rx_bps) t += `<br>↑ ${fmtBps(live.tx_bps)} · ↓ ${fmtBps(live.rx_bps)}`;
|
||||
if (live.errors) t += `<br><span class="fm-bad">${Object.entries(live.errors).map(([k, v]) => esc(k) + " " + v).join(", ")}</span>`;
|
||||
} else if (!h.node) {
|
||||
t += "<br><span class=\"fm-dim\">not one of this cluster's nodes</span>";
|
||||
t += `<br><span class="fm-dim">${esc(T("not_ours"))}</span>`;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
@@ -338,24 +366,24 @@
|
||||
const cables = map.links.filter((k) => k.from === s.guid || k.to === s.guid);
|
||||
const bad = cables.filter((k) => k.degraded);
|
||||
const rows = bad.map((k) => { const o = k.from === s.guid ? k.to : k.from; const on = map.switches.find((x) => x.guid === o) || map.hosts.find((x) => x.guid === o);
|
||||
return `<li>port ${k.from === s.guid ? k.from_port : k.to_port} → ${esc(on ? (on.name || on.node || on.desc) : o)}: ${esc(k.rate)}</li>`; }).join("");
|
||||
return `<button class="fm-close" aria-label="close">×</button><h4>${esc(s.name)}</h4>
|
||||
<dl><dt>tier</dt><dd>${s.tier >= 3 ? "core" : s.tier === 2 ? "spine" : "leaf"}</dd><dt>GUID</dt><dd><code>${esc(s.guid)}</code></dd>
|
||||
<dt>LID</dt><dd>${s.lid || "—"}</dd><dt>ports</dt><dd>${s.used} of ${s.ports} cabled</dd><dt>adapters</dt><dd>${s.hosts}</dd></dl>` +
|
||||
(bad.length ? `<p class="fm-bad">Degraded cables</p><ul>${rows}</ul>` : "<p class=\"fm-dim\">Every cable at its expected rate.</p>");
|
||||
return `<li>${esc(T("port_to", k.from === s.guid ? k.from_port : k.to_port, on ? (on.name || on.node || on.desc) : o, k.rate))}</li>`; }).join("");
|
||||
return `<button class="fm-close" aria-label="${esc(T("close"))}">×</button><h4>${esc(s.name)}</h4>
|
||||
<dl><dt>${esc(T("dt.tier"))}</dt><dd>${esc(tierName(s))}</dd><dt>GUID</dt><dd><code>${esc(s.guid)}</code></dd>
|
||||
<dt>LID</dt><dd>${s.lid || "—"}</dd><dt>${esc(T("dt.ports"))}</dt><dd>${esc(T("cabled", s.used, s.ports))}</dd><dt>${esc(T("dt.adapters"))}</dt><dd>${s.hosts}</dd></dl>` +
|
||||
(bad.length ? `<p class="fm-bad">${esc(T("degraded_cables"))}</p><ul>${rows}</ul>` : `<p class="fm-dim">${esc(T("all_good"))}</p>`);
|
||||
}
|
||||
function hostPanel(h, live, map) {
|
||||
const leaf = map.switches.find((x) => x.guid === h.leaf);
|
||||
let body = `<dl><dt>adapter</dt><dd>${esc(h.hca || h.desc)}</dd><dt>GUID</dt><dd><code>${esc(h.guid)}</code></dd>
|
||||
<dt>rail</dt><dd>${h.rail}</dd><dt>leaf</dt><dd>${esc(leaf ? leaf.name : "—")}</dd>`;
|
||||
let body = `<dl><dt>${esc(T("dt.adapter"))}</dt><dd>${esc(h.hca || h.desc)}</dd><dt>GUID</dt><dd><code>${esc(h.guid)}</code></dd>
|
||||
<dt>${esc(T("dt.rail"))}</dt><dd>${h.rail}</dd><dt>${esc(T("dt.leaf"))}</dt><dd>${esc(leaf ? leaf.name : "—")}</dd>`;
|
||||
if (live) {
|
||||
body += `<dt>state</dt><dd>${esc(live.state)} (${esc(live.phys_state)})</dd><dt>rate</dt><dd>${live.gbps} Gb/s</dd>
|
||||
<dt>sending</dt><dd>${fmtBps(live.tx_bps)}</dd><dt>receiving</dt><dd>${fmtBps(live.rx_bps)}</dd>`;
|
||||
body += `<dt>${esc(T("dt.state"))}</dt><dd>${esc(live.state)} (${esc(live.phys_state)})</dd><dt>${esc(T("dt.rate"))}</dt><dd>${live.gbps} Gb/s</dd>
|
||||
<dt>${esc(T("dt.sending"))}</dt><dd>${fmtBps(live.tx_bps)}</dd><dt>${esc(T("dt.receiving"))}</dt><dd>${fmtBps(live.rx_bps)}</dd>`;
|
||||
}
|
||||
body += "</dl>";
|
||||
if (live && live.errors) body += `<p class="fm-bad">${Object.entries(live.errors).map(([k, v]) => esc(k) + " " + v).join("<br>")}</p>`;
|
||||
const link = h.node ? `<a href="${esc(root.dataset.nodes + "/" + encodeURIComponent(h.node))}">open ${esc(h.node)} →</a>` : "";
|
||||
return `<button class="fm-close" aria-label="close">×</button><h4>${esc(h.node || h.desc || "adapter")}</h4>${body}${link}`;
|
||||
const link = h.node ? `<a href="${esc(root.dataset.nodes + "/" + encodeURIComponent(h.node))}">${esc(T("open_node", h.node))}</a>` : "";
|
||||
return `<button class="fm-close" aria-label="${esc(T("close"))}">×</button><h4>${esc(h.node || h.desc || T("adapter"))}</h4>${body}${link}`;
|
||||
}
|
||||
panel.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("fm-close")) { panel.hidden = true; state.selected = null; unhighlight(); }
|
||||
@@ -405,11 +433,11 @@
|
||||
const tx = live.reduce((a, l) => a + (l.tx_bps || 0), 0);
|
||||
const cells = [];
|
||||
if (m) {
|
||||
cells.push([m.switches.length, "switches"], [m.hosts.length, "adapters"], [m.links.length, "cables"],
|
||||
[m.degraded, "degraded", m.degraded ? "bad" : "ok"],
|
||||
[m.rails.length > 1 ? (m.rail_aligned ? "aligned" : "crossed") : "—", "rails", m.rails.length > 1 && !m.rail_aligned ? "bad" : ""]);
|
||||
cells.push([m.switches.length, T("stat.switches")], [m.hosts.length, T("stat.adapters")], [m.links.length, T("stat.cables")],
|
||||
[m.degraded, T("stat.degraded"), m.degraded ? "bad" : "ok"],
|
||||
[m.rails.length > 1 ? (m.rail_aligned ? T("stat.aligned") : T("stat.crossed")) : "—", T("stat.rails"), m.rails.length > 1 && !m.rail_aligned ? "bad" : ""]);
|
||||
}
|
||||
cells.push([fmtBps(tx), "sent now"]);
|
||||
cells.push([fmtBps(tx), T("stat.sent")]);
|
||||
stats.innerHTML = cells.map(([v, k, c]) => `<div class="fm-stat ${c || ""}"><b>${esc(v)}</b><span>${esc(k)}</span></div>`).join("");
|
||||
}
|
||||
|
||||
|
||||
+123
-162
@@ -1,7 +1,7 @@
|
||||
{{define "about"}}{{if not .Site}}<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<html lang="{{lang}}"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="description" content="llmgw serves models on your own GPUs behind one OpenAI-compatible API.">
|
||||
<meta name="description" content="{{t "about.meta.description"}}">
|
||||
{{end}}<title>llmgw</title>
|
||||
{{if .Site}}<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -176,52 +176,50 @@ footer a{color:var(--muted)}
|
||||
|
||||
<div class="wrap">
|
||||
<header class="bar">
|
||||
<a class="mark" href="#top" aria-label="llmgw home">
|
||||
<a class="mark" href="#top" aria-label="{{t "about.home_label"}}">
|
||||
<svg width="26" height="26" viewBox="0 0 26 26" aria-hidden="true">
|
||||
<rect x="1" y="1" width="24" height="24" rx="6" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M7 9h12M7 13h12M7 17h12" stroke="var(--fiber)" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
llmgw
|
||||
</a>
|
||||
<nav aria-label="Sections">
|
||||
<a href="#what">What it does</a>
|
||||
<a href="#fabrics">Fabrics</a>
|
||||
<a href="#install">Install</a>
|
||||
<a href="#proxy">Reverse proxy</a>
|
||||
<a href="#download">Download</a>
|
||||
<a href="https://gitea.mynodes.xyz/ilker/llmgw">Source</a>
|
||||
{{if not .Site}}<a href="/">This gateway</a>{{end}}
|
||||
<nav aria-label="{{t "about.nav.label"}}">
|
||||
<a href="#what">{{t "about.nav.what"}}</a>
|
||||
<a href="#fabrics">{{t "about.nav.fabrics"}}</a>
|
||||
<a href="#install">{{t "about.nav.install"}}</a>
|
||||
<a href="#proxy">{{t "about.nav.proxy"}}</a>
|
||||
<a href="#download">{{t "about.nav.download"}}</a>
|
||||
<a href="https://gitea.mynodes.xyz/ilker/llmgw">{{t "about.nav.source"}}</a>
|
||||
{{if not .Site}}<a href="/">{{t "about.nav.gateway"}}</a>{{end}}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="hero" id="top">
|
||||
<div>
|
||||
<p class="eyebrow">Self-hosted LLM gateway · {{.Version}}</p>
|
||||
<h1 style="margin-top:14px">One endpoint in front of <em>your own GPUs</em>.</h1>
|
||||
<p class="lede">llmgw runs vLLM, SGLang and llama.cpp across your servers and serves them
|
||||
behind one OpenAI-compatible API. It knows which GPUs share an NVLink domain or an InfiniBand
|
||||
fabric, and a model split across machines never straddles two.</p>
|
||||
<p class="eyebrow">{{t "about.hero.eyebrow" .Version}}</p>
|
||||
<h1 style="margin-top:14px">{{th "about.hero.title"}}</h1>
|
||||
<p class="lede">{{t "about.hero.lede"}}</p>
|
||||
<div class="cta">
|
||||
<a class="btn" href="#download">Download{{with .Release}} {{.}}{{end}}</a>
|
||||
<a class="btn ghost" href="https://gitea.mynodes.xyz/ilker/llmgw/src/branch/main/docs/quick-install.md">Install guide</a>
|
||||
<a class="btn" href="#download">{{t "about.hero.download"}}{{with .Release}} {{.}}{{end}}</a>
|
||||
<a class="btn ghost" href="https://gitea.mynodes.xyz/ilker/llmgw/src/branch/main/docs/quick-install.md">{{t "about.hero.guide"}}</a>
|
||||
</div>
|
||||
<div class="oneline" aria-label="Set up a control plane">
|
||||
<div class="oneline" aria-label="{{t "about.hero.oneline_label"}}">
|
||||
<code><span class="p">$ </span>sudo llmgw server init --admin you@example.com</code>
|
||||
<button class="copy" style="position:static" data-copy="sudo llmgw server init --admin you@example.com" type="button">Copy</button>
|
||||
<button class="copy" style="position:static" data-copy="sudo llmgw server init --admin you@example.com" type="button">{{t "about.copy"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figure class="schem">
|
||||
<svg viewBox="0 0 440 250" role="img" aria-labelledby="schem-t">
|
||||
<title id="schem-t">Clients reach one llmgw address. llmgw places a model's shards on GPUs inside one fabric at a time.</title>
|
||||
<title id="schem-t">{{t "about.schem.title"}}</title>
|
||||
<!-- clients -->
|
||||
<g>
|
||||
<rect class="s-box" x="4" y="58" width="92" height="30" rx="6"/>
|
||||
<text class="s-label" x="50" y="77" text-anchor="middle">openai sdk</text>
|
||||
<text class="s-label" x="50" y="77" text-anchor="middle" translate="no">openai sdk</text>
|
||||
<rect class="s-box" x="4" y="110" width="92" height="30" rx="6"/>
|
||||
<text class="s-label" x="50" y="129" text-anchor="middle">editor agent</text>
|
||||
<text class="s-label" x="50" y="129" text-anchor="middle">{{t "about.schem.editor"}}</text>
|
||||
<rect class="s-box" x="4" y="162" width="92" height="30" rx="6"/>
|
||||
<text class="s-label" x="50" y="181" text-anchor="middle">curl</text>
|
||||
<text class="s-label" x="50" y="181" text-anchor="middle" translate="no">curl</text>
|
||||
</g>
|
||||
<path id="c1" class="s-link" d="M96 73 C130 73 130 118 160 118"/>
|
||||
<path id="c2" class="s-link" d="M96 125 H160"/>
|
||||
@@ -234,7 +232,7 @@ footer a{color:var(--muted)}
|
||||
<path id="fb" class="s-link" d="M244 136 C272 136 272 188 300 188"/>
|
||||
<!-- fabric a -->
|
||||
<rect class="s-fab" x="300" y="14" width="136" height="98" rx="10"/>
|
||||
<text class="s-label" x="312" y="32">nvl72 · gb200</text>
|
||||
<text class="s-label" x="312" y="32" translate="no">nvl72 · gb200</text>
|
||||
<g transform="translate(312 42)">
|
||||
<rect class="s-gpu-a" width="22" height="22" rx="3"/><rect class="s-gpu-a" x="28" width="22" height="22" rx="3"/>
|
||||
<rect class="s-gpu-a" x="56" width="22" height="22" rx="3"/><rect class="s-gpu-a" x="84" width="22" height="22" rx="3"/>
|
||||
@@ -243,7 +241,7 @@ footer a{color:var(--muted)}
|
||||
</g>
|
||||
<!-- fabric b -->
|
||||
<rect class="s-fab" x="300" y="138" width="136" height="98" rx="10"/>
|
||||
<text class="s-label" x="312" y="156">ib-b · h100-80g</text>
|
||||
<text class="s-label" x="312" y="156" translate="no">ib-b · h100-80g</text>
|
||||
<g transform="translate(312 166)">
|
||||
<rect class="s-gpu-b" width="22" height="22" rx="3"/><rect class="s-gpu-b" x="28" width="22" height="22" rx="3"/>
|
||||
<rect class="s-gpu-idle" x="56" width="22" height="22" rx="3"/><rect class="s-gpu-idle" x="84" width="22" height="22" rx="3"/>
|
||||
@@ -257,50 +255,37 @@ footer a{color:var(--muted)}
|
||||
<circle class="pulse" r="4"><animateMotion dur="2.4s" begin="1.2s" repeatCount="indefinite"><mpath href="#fa"/></animateMotion></circle>
|
||||
<circle class="pulse b" r="4"><animateMotion dur="2.4s" begin="2s" repeatCount="indefinite"><mpath href="#fb"/></animateMotion></circle>
|
||||
</svg>
|
||||
<figcaption>A replica spanning nodes goes into one NVLink domain if one has room, otherwise onto
|
||||
one InfiniBand fabric. Two fabrics that cannot see each other are never bridged: the all-reduce
|
||||
would hang.</figcaption>
|
||||
<figcaption>{{t "about.schem.caption"}}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<section id="what">
|
||||
<div class="sec-head">
|
||||
<div>
|
||||
<h2>What it does</h2>
|
||||
<p class="intro">One static binary. It runs as the control plane, as the agent on every GPU
|
||||
node, and as the weights mirror.</p>
|
||||
<h2>{{t "about.what.title"}}</h2>
|
||||
<p class="intro">{{t "about.what.intro"}}</p>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div class="fact"><h3>OpenAI API, your hardware</h3>
|
||||
<p>Point any OpenAI client at it. Only the base URL changes. Streaming, tool calls,
|
||||
<code>/v1/models</code> and <code>/v1/responses</code> work as your client expects.</p></div>
|
||||
<div class="fact"><h3>Placement that knows the fabric</h3>
|
||||
<p>Agents report GPU class, memory, NVLink peers and InfiniBand ports. Replicas go where they
|
||||
fit, including ones larger than one machine, on the GPUs with the most NVLink pairs.</p></div>
|
||||
<div class="fact"><h3>Models load when asked for</h3>
|
||||
<p>Keep more models in the catalog than your GPUs hold. The first request starts one, the
|
||||
chat page shows it loading, and it stops again after the idle timeout.</p></div>
|
||||
<div class="fact"><h3>Keys, budgets, rate limits</h3>
|
||||
<p>Organisations, teams and projects, each with limits. Priority bands decide who waits
|
||||
when the cluster is full. Managers see their organisation's spend; people see their own.</p></div>
|
||||
<div class="fact"><h3>Editors connect from the account page</h3>
|
||||
<p>Pick Continue, Codex or Cline and a model. The page makes a key and hands over the config,
|
||||
filled in, or a one-line command that writes it.</p></div>
|
||||
<div class="fact"><h3>A chat page for quick questions</h3>
|
||||
<p>Markdown and maths rendered, stop mid-reply, tokens per second and time to first token,
|
||||
a system prompt and a temperature. The gateway keeps no conversations.</p></div>
|
||||
<div class="fact"><h3>A mirror you browse</h3>
|
||||
<p>Explore what is trending on Hugging Face, read a model card under its row, and download
|
||||
once for the whole cluster. Only what your nodes can run is shown by default.</p></div>
|
||||
<div class="fact"><h3>Nodes install their own engines</h3>
|
||||
<p>llama.cpp on every node; vLLM on every GPU node that can run it, from PyPI with every
|
||||
file hash-checked. A node that cannot run it says why.</p></div>
|
||||
<div class="fact"><h3>Upgrades that keep models running</h3>
|
||||
<p>Upgrade the control plane, then the agents. A restarted agent adopts the engines it was
|
||||
running instead of reloading them, so replicas stay up throughout.</p></div>
|
||||
<div class="fact"><h3>A dashboard with nothing to set up</h3>
|
||||
<p><code>/admin</code> shows capacity per GPU class, CPU and memory per node, who is using
|
||||
what, each node's page and logs. It fits a phone. Prometheus is optional.</p></div>
|
||||
<div class="fact"><h3>{{t "about.what.api.title"}}</h3>
|
||||
<p>{{th "about.what.api.text"}}</p></div>
|
||||
<div class="fact"><h3>{{t "about.what.placement.title"}}</h3>
|
||||
<p>{{t "about.what.placement.text"}}</p></div>
|
||||
<div class="fact"><h3>{{t "about.what.ondemand.title"}}</h3>
|
||||
<p>{{t "about.what.ondemand.text"}}</p></div>
|
||||
<div class="fact"><h3>{{t "about.what.keys.title"}}</h3>
|
||||
<p>{{t "about.what.keys.text"}}</p></div>
|
||||
<div class="fact"><h3>{{t "about.what.editors.title"}}</h3>
|
||||
<p>{{t "about.what.editors.text"}}</p></div>
|
||||
<div class="fact"><h3>{{t "about.what.chat.title"}}</h3>
|
||||
<p>{{t "about.what.chat.text"}}</p></div>
|
||||
<div class="fact"><h3>{{t "about.what.mirror.title"}}</h3>
|
||||
<p>{{t "about.what.mirror.text"}}</p></div>
|
||||
<div class="fact"><h3>{{t "about.what.engines.title"}}</h3>
|
||||
<p>{{t "about.what.engines.text"}}</p></div>
|
||||
<div class="fact"><h3>{{t "about.what.upgrades.title"}}</h3>
|
||||
<p>{{t "about.what.upgrades.text"}}</p></div>
|
||||
<div class="fact"><h3>{{t "about.what.dashboard.title"}}</h3>
|
||||
<p>{{th "about.what.dashboard.text"}}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -308,24 +293,19 @@ footer a{color:var(--muted)}
|
||||
<section id="fabrics">
|
||||
<div class="sec-head">
|
||||
<div>
|
||||
<h2>InfiniBand and NVLink, several of each</h2>
|
||||
<p class="intro">A site may have independent InfiniBand fabrics, eight-GPU HGX boards and NVL72
|
||||
racks side by side. llmgw tells them apart by what the adapters report, not by what a config
|
||||
file calls them.</p>
|
||||
<p class="intro"><a href="https://gitea.mynodes.xyz/ilker/llmgw/src/branch/main/docs/fabrics.md">Fabrics and NVLink guide</a></p>
|
||||
<h2>{{t "about.fabrics.title"}}</h2>
|
||||
<p class="intro">{{t "about.fabrics.intro"}}</p>
|
||||
<p class="intro"><a href="https://gitea.mynodes.xyz/ilker/llmgw/src/branch/main/docs/fabrics.md">{{t "about.fabrics.guide"}}</a></p>
|
||||
</div>
|
||||
<div class="fab-grid">
|
||||
<div class="order" aria-label="Where a replica spanning nodes is placed, in order">
|
||||
<div><b>1</b><h3>One NVLink domain</h3><p>NVL72 or NVLink Switch. Every card joined, IMEX up.
|
||||
Shards get <code>NCCL_MNNVL_ENABLE=1</code>.</p></div>
|
||||
<div><b>2</b><h3>One InfiniBand or RoCE fabric</h3><p>Nodes under one leaf first, nodes with a
|
||||
degraded cable last.</p></div>
|
||||
<div><b class="x">×</b><h3>Never across two</h3><p>Two fabrics given one name are placed as
|
||||
two, and the cluster page says so.</p></div>
|
||||
<div class="order" aria-label="{{t "about.fabrics.order_label"}}">
|
||||
<div><b>1</b><h3>{{t "about.fabrics.nvlink.title"}}</h3><p>{{th "about.fabrics.nvlink.text"}}</p></div>
|
||||
<div><b>2</b><h3>{{t "about.fabrics.ib.title"}}</h3><p>{{t "about.fabrics.ib.text"}}</p></div>
|
||||
<div><b class="x">×</b><h3>{{t "about.fabrics.never.title"}}</h3><p>{{t "about.fabrics.never.text"}}</p></div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Check a node in one command</h3>
|
||||
<div class="term" aria-label="Example output of llmgw doctor"><pre>$ sudo llmgw doctor
|
||||
<h3>{{t "about.fabrics.doctor"}}</h3>
|
||||
<div class="term" aria-label="{{t "about.fabrics.doctor_label"}}"><pre>$ sudo llmgw doctor
|
||||
<span class="fail">FAIL</span> nvlink this machine has NVSwitch but nvidia-fabricmanager is inactive
|
||||
<span class="dim">→ systemctl enable --now nvidia-fabricmanager</span>
|
||||
<span class="warn">WARN</span> fabric nvidia_peermem is not loaded: GPUDirect RDMA is off
|
||||
@@ -337,14 +317,10 @@ footer a{color:var(--muted)}
|
||||
<span class="ok">OK</span> agent NCCL bootstraps over ens3 (10.1.0.21)</pre></div>
|
||||
</div>
|
||||
<ul class="watch">
|
||||
<li><strong>Subnet managers, watched</strong>Master, priority, standbys and failovers per fabric.
|
||||
A fabric with no standby is flagged. llmgw does not run one.</li>
|
||||
<li><strong>A map of each fabric</strong><code>/admin/fabrics</code> draws switches in tiers,
|
||||
cables coloured by rate, degraded ones in red, and live traffic on your adapters.</li>
|
||||
<li><strong>NVLink domains as racks</strong><code>/admin/nvlink</code> shows which trays are
|
||||
joined, whose IMEX is down, which link runs degraded.</li>
|
||||
<li><strong>Ports re-read every heartbeat</strong>A port that goes down takes its fabric off the
|
||||
node within seconds. Counters and rates are exported to Prometheus.</li>
|
||||
<li><strong>{{t "about.fabrics.sm.title"}}</strong>{{t "about.fabrics.sm.text"}}</li>
|
||||
<li><strong>{{t "about.fabrics.map.title"}}</strong>{{th "about.fabrics.map.text"}}</li>
|
||||
<li><strong>{{t "about.fabrics.racks.title"}}</strong>{{th "about.fabrics.racks.text"}}</li>
|
||||
<li><strong>{{t "about.fabrics.ports.title"}}</strong>{{t "about.fabrics.ports.text"}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,44 +329,38 @@ footer a{color:var(--muted)}
|
||||
<section id="install">
|
||||
<div class="sec-head">
|
||||
<div>
|
||||
<h2>From nothing to a first request</h2>
|
||||
<p class="intro">Debian, Ubuntu or RHEL 10. NVIDIA drivers and the InfiniBand stack are
|
||||
yours to install; llmgw does the rest.</p>
|
||||
<p class="intro"><a href="https://gitea.mynodes.xyz/ilker/llmgw/src/branch/main/docs/install.md">The full guide</a>
|
||||
covers the mirror, GPUs and what to do when something does not work.</p>
|
||||
<h2>{{t "about.install.title"}}</h2>
|
||||
<p class="intro">{{t "about.install.intro"}}</p>
|
||||
<p class="intro">{{th "about.install.guide"}}</p>
|
||||
</div>
|
||||
<ol class="steps">
|
||||
<li>
|
||||
<h3>Put the binary on every machine</h3>
|
||||
<p>Static, no dependencies, the same file everywhere.</p>
|
||||
<div class="code"><button class="copy" type="button">Copy</button><pre><code>VERSION={{or .Release "vX.Y.Z"}}{{if not .Release}} <span class="c"># from the releases page</span>{{end}}
|
||||
<h3>{{t "about.install.binary.title"}}</h3>
|
||||
<p>{{t "about.install.binary.text"}}</p>
|
||||
<div class="code"><button class="copy" type="button">{{t "about.copy"}}</button><pre><code>VERSION={{or .Release "vX.Y.Z"}}{{if not .Release}} <span class="c"># from the releases page</span>{{end}}
|
||||
U=https://gitea.mynodes.xyz/ilker/llmgw/releases/download/$VERSION
|
||||
curl -fsSLO $U/llmgw-$VERSION-linux-amd64
|
||||
curl -fsSL $U/checksums.txt | sha256sum -c --ignore-missing
|
||||
sudo install -m0755 llmgw-$VERSION-linux-amd64 /usr/local/bin/llmgw</code></pre></div>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Set up the control plane</h3>
|
||||
<p>Creates the user, the certificate authority, the certificate, <code>server.yaml</code>
|
||||
and your administrator account, then starts the service. Safe to run again.
|
||||
Add <code>--host</code> for any name nodes use that the machine cannot see itself.</p>
|
||||
<div class="code"><button class="copy" type="button">Copy</button><pre><code>sudo llmgw server init --admin you@example.com</code></pre></div>
|
||||
<h3>{{t "about.install.server.title"}}</h3>
|
||||
<p>{{th "about.install.server.text"}}</p>
|
||||
<div class="code"><button class="copy" type="button">{{t "about.copy"}}</button><pre><code>sudo llmgw server init --admin you@example.com</code></pre></div>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Add a node</h3>
|
||||
<p>From the control plane, which holds the authority. The node gets its certificate,
|
||||
the binary and the agent, and installs its engine by itself.</p>
|
||||
<div class="code"><button class="copy" type="button">Copy</button><pre><code>deploy/bootstrap/onboard-node.sh --node gpu01 --addr 10.0.0.141 \
|
||||
<h3>{{t "about.install.node.title"}}</h3>
|
||||
<p>{{t "about.install.node.text"}}</p>
|
||||
<div class="code"><button class="copy" type="button">{{t "about.copy"}}</button><pre><code>deploy/bootstrap/onboard-node.sh --node gpu01 --addr 10.0.0.141 \
|
||||
--server https://10.0.0.140:7443 --fabric ib-a:mlx5_0,mlx5_1</code></pre></div>
|
||||
<div class="term" aria-label="Example output of llmgw ctl nodes"><pre>$ llmgw ctl nodes
|
||||
<div class="term" aria-label="{{t "about.install.node.output_label"}}"><pre>$ llmgw ctl nodes
|
||||
NODE STATE GPUS MEM CLASSES FABRICS REPLICAS LAST SEEN
|
||||
gpu01 <span class="ok">ready</span> 8/8 free 503G h100-80g ib-a 0 2s</pre></div>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Add a model and send a request</h3>
|
||||
<p>No restart. If it does not place, <code>llmgw ctl explain-placement</code> names every
|
||||
node and why it was turned down.</p>
|
||||
<div class="code"><button class="copy" type="button">Copy</button><pre><code>llmgw ctl model add -f qwen-coder-3b.yaml
|
||||
<h3>{{t "about.install.model.title"}}</h3>
|
||||
<p>{{th "about.install.model.text"}}</p>
|
||||
<div class="code"><button class="copy" type="button">{{t "about.copy"}}</button><pre><code>llmgw ctl model add -f qwen-coder-3b.yaml
|
||||
|
||||
curl https://llm.example.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer $LLMGW_API_KEY" \
|
||||
@@ -404,35 +374,32 @@ curl https://llm.example.com/v1/chat/completions \
|
||||
<section id="proxy">
|
||||
<div class="sec-head">
|
||||
<div>
|
||||
<h2>Behind a reverse proxy</h2>
|
||||
<p class="intro">Port 8080 is plain HTTP. Put it behind Caddy or nginx with a public
|
||||
certificate. Only 8080 goes through the proxy: agents reach 7443 directly, because it is
|
||||
mutual TLS with the cluster's own authority.</p>
|
||||
<p class="intro">Bind llmgw to loopback when the proxy is on the same machine:
|
||||
<code>listen: "127.0.0.1:8080"</code>.</p>
|
||||
<h2>{{t "about.proxy.title"}}</h2>
|
||||
<p class="intro">{{t "about.proxy.intro"}}</p>
|
||||
<p class="intro">{{th "about.proxy.loopback"}}</p>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<input type="radio" name="proxy" id="t-caddy" checked>
|
||||
<input type="radio" name="proxy" id="t-nginx">
|
||||
<div class="tablist" role="presentation">
|
||||
<div class="tablist" role="presentation" translate="no">
|
||||
<label for="t-caddy">Caddy</label>
|
||||
<label for="t-nginx">nginx</label>
|
||||
</div>
|
||||
<div class="panel p-caddy">
|
||||
<p class="meta"><span>Obtains and renews its own certificate. Replace <code>llm.example.com</code>.</span>
|
||||
<a href="{{.Download}}/Caddyfile">Caddyfile</a></p>
|
||||
<div class="code"><button class="copy" type="button">Copy</button><pre><code>{{.Caddy}}</code></pre></div>
|
||||
<p class="meta"><span>{{th "about.proxy.caddy"}}</span>
|
||||
<a href="{{.Download}}/Caddyfile" translate="no">Caddyfile</a></p>
|
||||
<div class="code"><button class="copy" type="button">{{t "about.copy"}}</button><pre><code>{{.Caddy}}</code></pre></div>
|
||||
</div>
|
||||
<div class="panel p-nginx">
|
||||
<p class="meta"><span>Certificate from <code>certbot --nginx</code>. Works on nginx 1.18 and later.</span>
|
||||
<a href="{{.Download}}/nginx.conf">nginx.conf</a></p>
|
||||
<div class="code"><button class="copy" type="button">Copy</button><pre><code>{{.Nginx}}</code></pre></div>
|
||||
<p class="meta"><span>{{th "about.proxy.nginx"}}</span>
|
||||
<a href="{{.Download}}/nginx.conf" translate="no">nginx.conf</a></p>
|
||||
<div class="code"><button class="copy" type="button">{{t "about.copy"}}</button><pre><code>{{.Nginx}}</code></pre></div>
|
||||
</div>
|
||||
<ul class="rules">
|
||||
<li><strong>Streams are not buffered</strong>Tokens reach the client as they are generated, not in one burst at the end.</li>
|
||||
<li><strong>The read timeout outlasts the model</strong>nginx's 60-second default turns a slow first token into a 504.</li>
|
||||
<li><strong>Forwarded headers are set, not passed on</strong>llmgw marks session cookies Secure from <code>X-Forwarded-Proto</code> and shows <code>X-Forwarded-Host</code> as the base URL.</li>
|
||||
<li><strong>Body limit matches the router</strong>32 MiB, so an oversized request is refused before it is uploaded.</li>
|
||||
<li><strong>{{t "about.proxy.stream.title"}}</strong>{{t "about.proxy.stream.text"}}</li>
|
||||
<li><strong>{{t "about.proxy.timeout.title"}}</strong>{{t "about.proxy.timeout.text"}}</li>
|
||||
<li><strong>{{t "about.proxy.headers.title"}}</strong>{{th "about.proxy.headers.text"}}</li>
|
||||
<li><strong>{{t "about.proxy.body.title"}}</strong>{{t "about.proxy.body.text"}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -441,33 +408,29 @@ curl https://llm.example.com/v1/chat/completions \
|
||||
<section id="download">
|
||||
<div class="sec-head">
|
||||
<div>
|
||||
<h2>Download</h2>
|
||||
<p class="intro">Built by CI from the tag and checked against <code>checksums.txt</code>.
|
||||
Every release is on the <a href="https://gitea.mynodes.xyz/ilker/llmgw/releases">releases page</a>
|
||||
with its changelog.</p>
|
||||
<h2>{{t "about.download.title"}}</h2>
|
||||
<p class="intro">{{th "about.download.intro"}}</p>
|
||||
</div>
|
||||
<div>
|
||||
{{if .Release}}
|
||||
<div class="ver"><span class="tag">{{.Release}}</span>{{with .Date}}<span class="eyebrow">Released {{.}}</span>{{end}}</div>
|
||||
<div class="ver"><span class="tag">{{.Release}}</span>{{with .Date}}<span class="eyebrow">{{t "about.download.released" .}}</span>{{end}}</div>
|
||||
<div class="dl-wrap">
|
||||
<table class="dl">
|
||||
<thead><tr><th>For</th><th>File</th><th>Notes</th></tr></thead>
|
||||
<thead><tr><th>{{t "about.download.col.for"}}</th><th>{{t "about.download.col.file"}}</th><th>{{t "about.download.col.notes"}}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Linux x86-64</td><td><a href="{{$.Download}}/llmgw-{{$.Release}}-linux-amd64"><code>llmgw-{{$.Release}}-linux-amd64</code></a></td><td class="note">Static. Runs on any libc.</td></tr>
|
||||
<tr><td>Linux ARM64</td><td><a href="{{$.Download}}/llmgw-{{$.Release}}-linux-arm64"><code>llmgw-{{$.Release}}-linux-arm64</code></a></td><td class="note">Static.</td></tr>
|
||||
<tr><td>Linux x86-64, NVLink</td><td><a href="{{$.Download}}/llmgw-{{$.Release}}-linux-amd64-nvml"><code>llmgw-{{$.Release}}-linux-amd64-nvml</code></a></td><td class="note">Sees NVLink topology through NVML. For PCIe boxes with NVLink bridges. Needs the driver.</td></tr>
|
||||
<tr><td>macOS Apple silicon</td><td><a href="{{$.Download}}/llmgw-{{$.Release}}-darwin-arm64"><code>llmgw-{{$.Release}}-darwin-arm64</code></a></td><td class="note">For <code>llmgw ctl</code> and trying it on a laptop.</td></tr>
|
||||
<tr><td>macOS Intel</td><td><a href="{{$.Download}}/llmgw-{{$.Release}}-darwin-amd64"><code>llmgw-{{$.Release}}-darwin-amd64</code></a></td><td class="note"></td></tr>
|
||||
<tr><td>Caddy</td><td><a href="{{$.Download}}/Caddyfile"><code>Caddyfile</code></a></td><td class="note">Reverse proxy for port 8080.</td></tr>
|
||||
<tr><td>nginx</td><td><a href="{{$.Download}}/nginx.conf"><code>nginx.conf</code></a></td><td class="note">Reverse proxy for port 8080.</td></tr>
|
||||
<tr><td>Checksums</td><td><a href="{{$.Download}}/checksums.txt"><code>checksums.txt</code></a></td><td class="note">SHA-256 of every file above.</td></tr>
|
||||
<tr><td translate="no">Linux x86-64</td><td><a href="{{$.Download}}/llmgw-{{$.Release}}-linux-amd64"><code>llmgw-{{$.Release}}-linux-amd64</code></a></td><td class="note">{{t "about.download.static_any"}}</td></tr>
|
||||
<tr><td translate="no">Linux ARM64</td><td><a href="{{$.Download}}/llmgw-{{$.Release}}-linux-arm64"><code>llmgw-{{$.Release}}-linux-arm64</code></a></td><td class="note">{{t "about.download.static"}}</td></tr>
|
||||
<tr><td translate="no">Linux x86-64, NVLink</td><td><a href="{{$.Download}}/llmgw-{{$.Release}}-linux-amd64-nvml"><code>llmgw-{{$.Release}}-linux-amd64-nvml</code></a></td><td class="note">{{t "about.download.nvml"}}</td></tr>
|
||||
<tr><td>{{t "about.download.mac_arm"}}</td><td><a href="{{$.Download}}/llmgw-{{$.Release}}-darwin-arm64"><code>llmgw-{{$.Release}}-darwin-arm64</code></a></td><td class="note">{{th "about.download.mac_note"}}</td></tr>
|
||||
<tr><td translate="no">macOS Intel</td><td><a href="{{$.Download}}/llmgw-{{$.Release}}-darwin-amd64"><code>llmgw-{{$.Release}}-darwin-amd64</code></a></td><td class="note"></td></tr>
|
||||
<tr><td translate="no">Caddy</td><td><a href="{{$.Download}}/Caddyfile"><code>Caddyfile</code></a></td><td class="note">{{t "about.download.proxy_note"}}</td></tr>
|
||||
<tr><td translate="no">nginx</td><td><a href="{{$.Download}}/nginx.conf"><code>nginx.conf</code></a></td><td class="note">{{t "about.download.proxy_note"}}</td></tr>
|
||||
<tr><td>{{t "about.download.checksums"}}</td><td><a href="{{$.Download}}/checksums.txt"><code>checksums.txt</code></a></td><td class="note">{{t "about.download.checksums_note"}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="intro" style="margin-top:0">This is a development build ({{.Version}}). Take a release
|
||||
from the <a href="https://gitea.mynodes.xyz/ilker/llmgw/releases">releases page</a>, where every
|
||||
file is listed with its checksum.</p>
|
||||
<p class="intro" style="margin-top:0">{{th "about.download.dev" .Version}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -476,30 +439,27 @@ curl https://llm.example.com/v1/chat/completions \
|
||||
<section id="status">
|
||||
<div class="sec-head">
|
||||
<div>
|
||||
<h2>Where it stands</h2>
|
||||
<p class="intro">Pre-1.0: configuration formats may change between minor versions.
|
||||
This is what has run on real hardware, and what has not yet.</p>
|
||||
<h2>{{t "about.status.title"}}</h2>
|
||||
<p class="intro">{{t "about.status.intro"}}</p>
|
||||
</div>
|
||||
<div class="status">
|
||||
<div class="proven">
|
||||
<h3>Run on real clusters</h3>
|
||||
<h3>{{t "about.status.proven"}}</h3>
|
||||
<ul>
|
||||
<li>Single-node replicas on real GPUs and on CPU</li>
|
||||
<li>On-demand loading and idle shutdown</li>
|
||||
<li>Keys, budgets, rate limits and priority bands</li>
|
||||
<li>Rolling upgrades with no dropped replicas</li>
|
||||
<li>SQLite and PostgreSQL stores</li>
|
||||
<li>Chat, editor setup and the Responses API against a six-node test cluster</li>
|
||||
<li>{{t "about.status.proven.single"}}</li>
|
||||
<li>{{t "about.status.proven.ondemand"}}</li>
|
||||
<li>{{t "about.status.proven.keys"}}</li>
|
||||
<li>{{t "about.status.proven.upgrades"}}</li>
|
||||
<li>{{t "about.status.proven.stores"}}</li>
|
||||
<li>{{t "about.status.proven.chat"}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="open">
|
||||
<h3>Not yet proven</h3>
|
||||
<h3>{{t "about.status.open"}}</h3>
|
||||
<ul>
|
||||
<li>InfiniBand and NVLink on a real site: written and tested against the real output of
|
||||
<code>sminfo</code>, <code>ibnetdiscover</code> and <code>nvidia-smi</code>, but not yet run
|
||||
on the hardware. Run <code>llmgw doctor --json</code> on a few nodes first.</li>
|
||||
<li>Two control planes over long periods</li>
|
||||
<li>Nodes do not share downloaded weights; the mirror keeps each download local</li>
|
||||
<li>{{th "about.status.open.fabrics"}}</li>
|
||||
<li>{{t "about.status.open.planes"}}</li>
|
||||
<li>{{t "about.status.open.weights"}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -508,25 +468,26 @@ curl https://llm.example.com/v1/chat/completions \
|
||||
|
||||
<footer>
|
||||
<span>llmgw {{.Version}}</span>
|
||||
<span><a href="https://gitea.mynodes.xyz/ilker/llmgw">Source</a> ·
|
||||
<a href="https://gitea.mynodes.xyz/ilker/llmgw/src/branch/main/CHANGELOG.md">Changelog</a> ·
|
||||
<a href="https://gitea.mynodes.xyz/ilker/llmgw/src/branch/main/docs/reference.md">Configuration reference</a></span>
|
||||
<span><a href="https://gitea.mynodes.xyz/ilker/llmgw">{{t "about.nav.source"}}</a> ·
|
||||
<a href="https://gitea.mynodes.xyz/ilker/llmgw/src/branch/main/CHANGELOG.md">{{t "about.footer.changelog"}}</a> ·
|
||||
<a href="https://gitea.mynodes.xyz/ilker/llmgw/src/branch/main/docs/reference.md">{{t "about.footer.reference"}}</a></span>{{if not .Site}}{{template "langs"}}{{end}}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var say = { copy: {{t "about.copy"}}, copied: {{t "about.copied"}}, selected: {{t "about.selected"}} };
|
||||
document.querySelectorAll('.copy').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var text = b.dataset.copy || b.parentElement.querySelector('pre').innerText;
|
||||
var done = function (label) { b.textContent = label; setTimeout(function () { b.textContent = 'Copy'; }, 1600); };
|
||||
var done = function (label) { b.textContent = label; setTimeout(function () { b.textContent = say.copy; }, 1600); };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function () { done('Copied'); }, function () { select(); });
|
||||
navigator.clipboard.writeText(text).then(function () { done(say.copied); }, function () { select(); });
|
||||
} else { select(); }
|
||||
function select() {
|
||||
var el = b.dataset.copy ? b.parentElement.querySelector('code') : b.parentElement.querySelector('pre');
|
||||
var r = document.createRange(); r.selectNodeContents(el);
|
||||
var s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
|
||||
done('Selected');
|
||||
done(say.selected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,63 +1,59 @@
|
||||
{{define "signup"}}<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<html lang="{{lang}}"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sign up · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
<title>{{t "signup.title"}}</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
</head><body><div class="wrap narrow">
|
||||
<header><h1>Create an account</h1>
|
||||
<p class="sub">An operator approves it before it can be used.</p></header>
|
||||
<header><h1>{{t "signup.heading"}}</h1>
|
||||
<p class="sub">{{t "signup.sub"}}</p></header>
|
||||
|
||||
{{if .Done}}
|
||||
<p>Thank you. Your account is waiting for approval.</p>
|
||||
<p class="note">You will not get an email from this gateway — it does not send any.
|
||||
Tell whoever you asked for access that you have signed up.</p>
|
||||
<p><a href="/login">Sign in</a> once you have been approved.</p>
|
||||
<p>{{t "signup.thanks"}}</p>
|
||||
<p class="note">{{t "signup.no_email"}}</p>
|
||||
<p>{{th "signup.sign_in_once_approved"}}</p>
|
||||
{{/* The same page whether or not the address already had an account, so
|
||||
it says what to do in either case without saying which this was. */}}
|
||||
<p class="note">If this address already had an account, nothing new was made:
|
||||
<a href="/login">sign in</a> with it, or, if you have forgotten its password, ask
|
||||
an operator to reset it.</p>
|
||||
<p class="note">{{th "signup.already_had"}}</p>
|
||||
{{else}}
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" autocomplete="on">
|
||||
<label>Email<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>
|
||||
<label>Name<input type="text" name="name" value="{{.Name}}" autocomplete="name"></label>
|
||||
<label>Password<input type="password" name="password" required minlength="{{.MinPassword}}"
|
||||
<label>{{t "signup.email"}}<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>
|
||||
<label>{{t "signup.name"}}<input type="text" name="name" value="{{.Name}}" autocomplete="name"></label>
|
||||
<label>{{t "signup.password"}}<input type="password" name="password" required minlength="{{.MinPassword}}"
|
||||
autocomplete="new-password"></label>
|
||||
<p class="note">At least {{.MinPassword}} characters. Length is the only rule —
|
||||
a long phrase beats a short one with punctuation in it.</p>
|
||||
<button type="submit">Sign up</button>
|
||||
<p class="note">{{t "signup.password_rule" .MinPassword}}</p>
|
||||
<button type="submit">{{t "signup.submit"}}</button>
|
||||
</form>
|
||||
<p class="note">Already have an account? <a href="/login">Sign in</a>.</p>
|
||||
<p class="note">{{th "signup.have_account"}}</p>
|
||||
{{end}}
|
||||
<footer><span>llmgw {{.Version}}</span><span><a href="/">about this gateway</a></span></footer>
|
||||
<footer><span>llmgw {{.Version}}</span><span><a href="/">{{t "common.about_gateway"}}</a></span>{{template "langs"}}</footer>
|
||||
</div></body></html>{{end}}
|
||||
|
||||
{{define "login"}}<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<html lang="{{lang}}"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sign in · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
<title>{{t "login.title"}}</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
</head><body><div class="wrap narrow">
|
||||
<header><h1>Sign in</h1></header>
|
||||
<header><h1>{{t "login.heading"}}</h1></header>
|
||||
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" autocomplete="on">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Email<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>
|
||||
<label>Password<input type="password" name="password" required autocomplete="current-password"></label>
|
||||
<button type="submit">Sign in</button>
|
||||
<label>{{t "login.email"}}<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>
|
||||
<label>{{t "login.password"}}<input type="password" name="password" required autocomplete="current-password"></label>
|
||||
<button type="submit">{{t "login.submit"}}</button>
|
||||
</form>
|
||||
{{if .SSO}}<p><a class="btn" href="/login/oidc">Sign in with {{.SSO}}</a></p>{{end}}
|
||||
{{if .SignupOpen}}<p class="note">No account? <a href="/signup">Sign up</a>.</p>{{end}}
|
||||
<footer><span>llmgw {{.Version}}</span><span><a href="/">about this gateway</a></span></footer>
|
||||
{{if .SSO}}<p><a class="btn" href="/login/oidc">{{t "login.with_sso" .SSO}}</a></p>{{end}}
|
||||
{{if .SignupOpen}}<p class="note">{{th "login.no_account"}}</p>{{end}}
|
||||
<footer><span>llmgw {{.Version}}</span><span><a href="/">{{t "common.about_gateway"}}</a></span>{{template "langs"}}</footer>
|
||||
</div></body></html>{{end}}
|
||||
|
||||
{{define "profile"}}<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<html lang="{{lang}}"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Account · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
<title>{{t "profile.title"}}</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
</head><body><div class="wrap">
|
||||
<header>
|
||||
<h1>Your account</h1>
|
||||
<h1>{{t "profile.heading"}}</h1>
|
||||
<p class="sub">{{.User.Email}}{{if ne .User.Name .User.Email}} ({{.User.Name}}){{end}}</p>
|
||||
{{template "nav" .Nav}}
|
||||
</header>
|
||||
@@ -65,10 +61,10 @@ an operator to reset it.</p>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
|
||||
{{if .NewKey}}
|
||||
<h2>Your new key</h2>
|
||||
<p>This is shown once. Nothing can produce it again — only its hash is kept.</p>
|
||||
<h2>{{t "profile.new_key"}}</h2>
|
||||
<p>{{t "profile.shown_once"}}</p>
|
||||
<pre>{{.NewKey}}</pre>
|
||||
<p>Put it in a password manager now.</p>
|
||||
<p>{{t "profile.store_it"}}</p>
|
||||
<pre>curl {{.BaseURL}}/v1/chat/completions \
|
||||
-H "Authorization: Bearer {{.NewKey}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -77,59 +73,58 @@ an operator to reset it.</p>
|
||||
|
||||
{{with .Connect}}
|
||||
<section class="connect-result" id="connected">
|
||||
<h2>{{.ToolName}} is ready to connect</h2>
|
||||
<p>A key was made for it, <strong>shown this once</strong> — it is in everything below. Nothing can produce it again.</p>
|
||||
<h2>{{t "connect.ready" .ToolName}}</h2>
|
||||
<p>{{th "connect.shown_once"}}</p>
|
||||
{{if .Command}}
|
||||
<h3>One command</h3>
|
||||
<p class="note">Writes the file below{{if eq .Tool "codex"}} into your Codex configuration{{end}}, keeping a copy of anything it replaces.</p>
|
||||
<div class="copyable"><pre>{{.Command}}</pre><button type="button" class="link copy">copy</button></div>
|
||||
<h3>{{t "connect.one_command"}}</h3>
|
||||
<p class="note">{{if eq .Tool "codex"}}{{t "connect.command_note_codex"}}{{else}}{{t "connect.command_note"}}{{end}}</p>
|
||||
<div class="copyable"><pre>{{.Command}}</pre><button type="button" class="link copy">{{t "common.copy"}}</button></div>
|
||||
{{end}}
|
||||
{{range .Files}}
|
||||
<h3>Or by hand: <code>{{.Path}}</code></h3>
|
||||
<div class="copyable"><pre>{{.Body}}</pre><button type="button" class="link copy">copy</button>
|
||||
<button type="button" class="link download-file" data-name="{{.Name}}">download</button></div>
|
||||
<h3>{{th "connect.by_hand" .Path}}</h3>
|
||||
<div class="copyable"><pre>{{.Body}}</pre><button type="button" class="link copy">{{t "common.copy"}}</button>
|
||||
<button type="button" class="link download-file" data-name="{{.Name}}">{{t "common.download"}}</button></div>
|
||||
{{end}}
|
||||
{{if .Fields}}
|
||||
<h3>Settings to fill in</h3>
|
||||
<h3>{{t "connect.fields"}}</h3>
|
||||
<table class="rows compact narrow-table connect-fields">{{range .Fields}}
|
||||
<tr><th>{{.Label}}</th><td><code>{{.Value}}</code></td><td><button type="button" class="link copy" data-value="{{.Value}}">copy</button></td></tr>{{end}}
|
||||
<tr><th>{{.Label}}</th><td><code>{{.Value}}</code></td><td><button type="button" class="link copy" data-value="{{.Value}}">{{t "common.copy"}}</button></td></tr>{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
<h3>Then</h3>
|
||||
<h3>{{t "connect.then"}}</h3>
|
||||
<p>{{if eq .Tool "codex"}}<code>{{.Run}}</code>{{else}}{{.Run}}{{end}}</p>
|
||||
{{range .Notes}}<p class="note">{{.}}</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<h2>Connect your editor</h2>
|
||||
<p class="note">A key of its own for the tool, and its configuration with the key, this gateway and a model
|
||||
already in it. Revoke the key below to disconnect it.</p>
|
||||
<p class="note">From your own code: the official OpenAI and Anthropic SDKs work as they are —
|
||||
the <a href="/docs/clients">client guide</a> shows how, in Python, TypeScript, Go and curl.</p>
|
||||
<h2>{{t "connect.heading"}}</h2>
|
||||
<p class="note">{{t "connect.intro"}}</p>
|
||||
<p class="note">{{th "connect.own_code"}}</p>
|
||||
<form method="post" action="/profile/connect#connected" class="connect-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="field-row">
|
||||
<label>Tool
|
||||
<label>{{t "connect.tool"}}
|
||||
<select name="tool">{{range .Tools}}<option value="{{.Key}}">{{.Name}}</option>{{end}}</select></label>
|
||||
<label>Model
|
||||
<label>{{t "connect.model"}}
|
||||
{{if .Models}}<select name="model">{{range .Models}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
||||
{{else}}<input type="text" name="model" placeholder="a model the gateway serves" required>{{end}}</label>
|
||||
{{if .Contexts}}<label>Spends against
|
||||
{{else}}<input type="text" name="model" placeholder="{{t "connect.model_placeholder"}}" required>{{end}}</label>
|
||||
{{if .Contexts}}<label>{{t "connect.spends_against"}}
|
||||
<select name="context">{{range .Contexts}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select></label>{{end}}
|
||||
</div>
|
||||
<button type="submit">Make a key and the configuration</button>
|
||||
<button type="submit">{{t "connect.submit"}}</button>
|
||||
</form>
|
||||
<script>
|
||||
// Copy and download for what the connect panel shows, done in the page: the
|
||||
// key in it must not go back to the server to become a file.
|
||||
const copiedWord = {{t "common.copied"}}, copyPrompt = {{t "profile.copy_prompt"}};
|
||||
document.addEventListener("click", (e) => {
|
||||
const b = e.target.closest("button.copy, button.download-file");
|
||||
if (!b) return;
|
||||
const text = b.dataset.value || b.parentElement.querySelector("pre").textContent;
|
||||
if (b.classList.contains("copy")) {
|
||||
const done = () => { const was = b.textContent; b.textContent = "copied"; setTimeout(() => (b.textContent = was), 1500); };
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(text).then(done, () => window.prompt("Copy", text));
|
||||
else window.prompt("Copy", text);
|
||||
const done = () => { const was = b.textContent; b.textContent = copiedWord; setTimeout(() => (b.textContent = was), 1500); };
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(text).then(done, () => window.prompt(copyPrompt, text));
|
||||
else window.prompt(copyPrompt, text);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
|
||||
@@ -140,88 +135,82 @@ document.addEventListener("click", (e) => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<h2>Create a key</h2>
|
||||
<h2>{{t "profile.create_key"}}</h2>
|
||||
<form method="post" action="/profile/keys" class="narrow">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>What is it for?<input type="text" name="name" placeholder="laptop, ci, notebook" required></label>
|
||||
<label>{{t "profile.key_for"}}<input type="text" name="name" placeholder="{{t "profile.key_for_placeholder"}}" required></label>
|
||||
{{if .Contexts}}
|
||||
<label>Spends against
|
||||
<label>{{t "profile.spends_against"}}
|
||||
<select name="context">
|
||||
{{range .Contexts}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
|
||||
</select></label>
|
||||
<button type="submit">Create</button>
|
||||
<button type="submit">{{t "profile.create"}}</button>
|
||||
{{else}}
|
||||
<p class="note">You are in no project yet, and personal keys are off here.
|
||||
Ask an administrator to add you to one.</p>
|
||||
<p class="note">{{t "profile.no_project"}}</p>
|
||||
{{end}}
|
||||
</form>
|
||||
|
||||
<h2>Keys</h2>
|
||||
<h2>{{t "profile.keys"}}</h2>
|
||||
{{if .Keys}}
|
||||
<table class="rows"><tr><th>Name</th><th>Key</th><th>Spends against</th><th>Created</th><th>Last used</th><th>State</th><th></th></tr>
|
||||
<table class="rows"><tr><th>{{t "profile.col.name"}}</th><th>{{t "profile.col.key"}}</th><th>{{t "profile.col.spends_against"}}</th><th>{{t "profile.col.created"}}</th><th>{{t "profile.col.last_used"}}</th><th>{{t "profile.col.state"}}</th><th></th></tr>
|
||||
{{range .Keys}}<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><code>{{.Prefix}}</code></td>
|
||||
<td>{{if .ProjectID}}{{or (index $.Where .ProjectID) "a project you have left"}}{{else}}Personal{{end}}</td>
|
||||
<td>{{if .ProjectID}}{{or (index $.Where .ProjectID) (t "profile.left_project")}}{{else}}{{t "profile.personal"}}{{end}}</td>
|
||||
<td>{{date .CreatedAt}}</td>
|
||||
<td>{{ago .LastUsedAt}}</td>
|
||||
<td class="state state-{{state .}}">{{state .}}</td>
|
||||
<td class="state state-{{state .}}">{{t (print "profile.state." (state .))}}</td>
|
||||
<td>{{if eq (state .) "active"}}
|
||||
<form method="post" action="/profile/keys/{{.ID}}/revoke">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link danger">Revoke</button>
|
||||
<button type="submit" class="link danger">{{t "profile.revoke"}}</button>
|
||||
</form>
|
||||
{{end}}</td>
|
||||
</tr>{{end}}</table>
|
||||
<p class="note">Revoking takes effect within the gateway's cache window, up to a
|
||||
minute. To rotate: create the new key, switch over, wait for "last used" on the
|
||||
old one to stop moving, then revoke it.</p>
|
||||
{{else}}<p class="empty">You have no keys yet.</p>{{end}}
|
||||
<p class="note">{{t "profile.revoke_note"}}</p>
|
||||
{{else}}<p class="empty">{{t "profile.no_keys"}}</p>{{end}}
|
||||
|
||||
<h2>Where you are</h2>
|
||||
<h2>{{t "profile.where"}}</h2>
|
||||
{{if .Memberships}}
|
||||
<p>{{range $i, $m := .Memberships}}{{if $i}} · {{end}}{{$m.Path}} <span class="opt">{{$m.Scope}}</span>{{end}}</p>
|
||||
{{else}}<p class="empty">You are not a member of any organization, team or project.</p>{{end}}
|
||||
<p>{{range $i, $m := .Memberships}}{{if $i}} · {{end}}{{$m.Path}} <span class="opt">{{t (print "profile.scope." $m.Scope)}}</span>{{end}}</p>
|
||||
{{else}}<p class="empty">{{t "profile.no_memberships"}}</p>{{end}}
|
||||
|
||||
<h2>Your use and limits</h2>
|
||||
<h2>{{t "selfuse.heading"}}</h2>
|
||||
{{with .Own}}
|
||||
<div class="tiles own-use">
|
||||
<div class="tile"><div class="v">{{compact .Requests}}</div><div class="k">requests · 30 days</div></div>
|
||||
<div class="tile"><div class="v">{{compact .Tokens}}</div><div class="k">tokens · 30 days</div></div>
|
||||
<div class="tile"><div class="v">{{.Spend}}</div><div class="k">spend · 30 days</div></div>
|
||||
<div class="tile"><div class="v">{{compact .Requests}}</div><div class="k">{{t "selfuse.requests_30d"}}</div></div>
|
||||
<div class="tile"><div class="v">{{compact .Tokens}}</div><div class="k">{{t "selfuse.tokens_30d"}}</div></div>
|
||||
<div class="tile"><div class="v">{{.Spend}}</div><div class="k">{{t "selfuse.spend_30d"}}</div></div>
|
||||
</div>
|
||||
{{with .Chart}}<div class="chart-wrap"><h3 class="chart-title">Tokens per day</h3>{{.}}</div>{{end}}
|
||||
{{with .Chart}}<div class="chart-wrap"><h3 class="chart-title">{{t "selfuse.tokens_per_day"}}</h3>{{.}}</div>{{end}}
|
||||
{{end}}
|
||||
{{range .LimitsBy}}
|
||||
<h3 class="limits-for">{{.Label}}{{with .Use}} <span class="opt">— {{compact .Requests}} requests, {{compact .Tokens}} tokens in 30 days</span>{{end}}</h3>
|
||||
<h3 class="limits-for">{{.Label}}{{with .Use}} <span class="opt">{{t "selfuse.place_use" (compact .Requests) (compact .Tokens)}}</span>{{end}}</h3>
|
||||
{{range .Budgets}}
|
||||
<div class="budget{{if .Over}} over{{else if .Near}} near{{end}}">
|
||||
<div class="budget-head"><span>{{.Label}}</span><span><b>{{.Used}}</b> of {{.Budget}} {{.Period}}</span></div>
|
||||
<div class="budget-head"><span>{{.Label}}</span><span>{{th "selfuse.budget_of" .Used .Budget .Period}}</span></div>
|
||||
<div class="budget-bar"><span style="width: {{.Percent}}%"></span></div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Rows}}
|
||||
<table class="rows compact narrow-table"><tr><th>Limit</th><th class="num">Value</th></tr>
|
||||
<table class="rows compact narrow-table"><tr><th>{{t "selfuse.col.limit"}}</th><th class="num">{{t "selfuse.col.value"}}</th></tr>
|
||||
{{range .Rows}}<tr><td>{{.Name}}</td><td class="num">{{.Value}}</td></tr>{{end}}</table>
|
||||
{{else}}<p class="empty">No limits here.</p>{{end}}
|
||||
{{else}}<p class="empty">Nowhere to spend yet.</p>{{end}}
|
||||
<p class="note">Everything that applies, from the organization down to you: the
|
||||
strictest value wins.</p>
|
||||
{{else}}<p class="empty">{{t "selfuse.no_limits"}}</p>{{end}}
|
||||
{{else}}<p class="empty">{{t "selfuse.nowhere"}}</p>{{end}}
|
||||
<p class="note">{{t "selfuse.strictest"}}</p>
|
||||
|
||||
<h2>Password</h2>
|
||||
{{if .PasswordChanged}}<p>Your password was changed. Any other browser you were
|
||||
signed in on has been signed out.</p>{{end}}
|
||||
<h2>{{t "profile.password"}}</h2>
|
||||
{{if .PasswordChanged}}<p>{{t "profile.password_changed"}}</p>{{end}}
|
||||
{{if .PasswordError}}<p class="error">{{.PasswordError}}</p>{{end}}
|
||||
<form method="post" action="/profile/password" autocomplete="on" class="narrow">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Current password<input type="password" name="current" required
|
||||
<label>{{t "profile.current_password"}}<input type="password" name="current" required
|
||||
autocomplete="current-password"></label>
|
||||
<label>New password<input type="password" name="new" required
|
||||
<label>{{t "profile.new_password"}}<input type="password" name="new" required
|
||||
minlength="{{.MinPassword}}" autocomplete="new-password"></label>
|
||||
<button type="submit">Change password</button>
|
||||
<button type="submit">{{t "profile.change_password"}}</button>
|
||||
</form>
|
||||
<p class="note">Changing it signs out every other browser, which is the point if
|
||||
you are changing it because something went wrong.</p>
|
||||
<p class="note">{{t "profile.password_note"}}</p>
|
||||
|
||||
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
|
||||
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
|
||||
</div></body></html>{{end}}
|
||||
@@ -1,46 +1,47 @@
|
||||
{{define "admin_guard"}}<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<html lang="{{lang}}"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Guardrails · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
<title>{{t "guard.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
</head><body><div class="wrap">
|
||||
<header>
|
||||
<h1>Guardrails</h1>
|
||||
<p class="sub">What the policies did, where, and by which rule — names and counts, never what was written.</p>
|
||||
<h1>{{t "guard.title"}}</h1>
|
||||
<p class="sub">{{t "guard.sub"}}</p>
|
||||
{{template "nav" .Nav}}
|
||||
</header>
|
||||
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
||||
|
||||
<h2>Policies</h2>
|
||||
<h2>{{t "guard.policies"}}</h2>
|
||||
{{if .Policies}}
|
||||
<p>Defined in <code>server.yaml</code>: {{range $i, $p := .Policies}}{{if $i}}, {{end}}<code>{{$p}}</code>{{end}}.
|
||||
Applied to every request: {{if .Default}}{{range $i, $p := .Default}}{{if $i}}, {{end}}<code>{{$p}}</code>{{end}}{{else}}none{{end}}.</p>
|
||||
<p>{{th "guard.defined_in"}} {{range $i, $p := .Policies}}{{if $i}}, {{end}}<code>{{$p}}</code>{{end}}.
|
||||
{{t "guard.applied"}} {{if .Default}}{{range $i, $p := .Default}}{{if $i}}, {{end}}<code>{{$p}}</code>{{end}}{{else}}{{t "common.none"}}{{end}}.</p>
|
||||
{{if .Attachments}}
|
||||
<table class="rows">
|
||||
<tr><th>Scope</th><th>Who</th><th>Policy</th></tr>
|
||||
<tr><th>{{t "guard.col.scope"}}</th><th>{{t "guard.col.who"}}</th><th>{{t "guard.col.policy"}}</th></tr>
|
||||
{{range .Attachments}}<tr><td>{{.Scope}}</td><td>{{.Who}}</td><td><code>{{.Policy}}</code></td></tr>{{end}}
|
||||
</table>
|
||||
{{else}}<p class="sub">Attached nowhere else. <code>llmgw ctl guard attach project:ORG/TEAM/PROJECT POLICY</code> attaches one.</p>{{end}}
|
||||
{{else}}<p class="sub">{{th "guard.attached_nowhere"}}</p>{{end}}
|
||||
{{else}}
|
||||
<p>No policies: add a <code>guard:</code> section to <code>server.yaml</code> (docs/guardrails.md).</p>
|
||||
<p>{{th "guard.no_policies"}}</p>
|
||||
{{end}}
|
||||
|
||||
<h2>What they did</h2>
|
||||
<p class="sub">Over the last
|
||||
<h2>{{t "guard.what_they_did"}}</h2>
|
||||
<p class="sub">{{t "guard.over_last"}}
|
||||
{{range .Windows}}{{if eq . $.Window}}<strong>{{.}}</strong>{{else}}<a href="?window={{.}}">{{.}}</a>{{end}} {{end}}</p>
|
||||
{{if .Counts}}
|
||||
<table class="rows">
|
||||
<tr><th>Where</th><th>Action</th><th>Policy</th><th>Category</th><th class="num">Requests</th></tr>
|
||||
{{range .Counts}}<tr><td>{{.Where}}</td><td>{{.Action}}</td><td><code>{{.Policy}}</code></td><td>{{.Category}}</td><td class="num">{{.Count}}</td></tr>{{end}}
|
||||
<tr><th>{{t "guard.col.where"}}</th><th>{{t "guard.col.action"}}</th><th>{{t "guard.col.policy"}}</th><th>{{t "guard.col.category"}}</th><th class="num">{{t "guard.col.requests"}}</th></tr>
|
||||
{{range .Counts}}<tr><td>{{.Where}}</td><td>{{.Action}}</td><td><code>{{.Policy}}</code></td><td>{{.Category}}</td><td class="num">{{count .Count}}</td></tr>{{end}}
|
||||
</table>
|
||||
{{else}}<p class="sub">Nothing flagged, redacted or blocked.</p>{{end}}
|
||||
{{else}}<p class="sub">{{t "guard.nothing"}}</p>{{end}}
|
||||
|
||||
{{if .Events}}
|
||||
<h2>Recent</h2>
|
||||
<h2>{{t "guard.recent"}}</h2>
|
||||
<table class="rows">
|
||||
<tr><th>When (UTC)</th><th>Request</th><th>Where</th><th>Model</th><th>Action</th><th>Policy</th><th>Category</th><th class="num">Status</th><th class="num">ms</th></tr>
|
||||
<tr><th>{{t "guard.col.when"}}</th><th>{{t "guard.col.request"}}</th><th>{{t "guard.col.where"}}</th><th>{{t "guard.col.model"}}</th><th>{{t "guard.col.action"}}</th><th>{{t "guard.col.policy"}}</th><th>{{t "guard.col.category"}}</th><th class="num">{{t "guard.col.status"}}</th><th class="num">ms</th></tr>
|
||||
{{range .Events}}<tr><td>{{.At}}</td><td><code>{{.RequestID}}</code></td><td>{{.Where}}</td><td>{{.Model}}</td>
|
||||
<td>{{.Action}}</td><td><code>{{.Policy}}</code></td><td>{{.Category}}</td><td class="num">{{.Status}}</td><td class="num">{{.Ms}}</td></tr>{{end}}
|
||||
<td>{{.Action}}</td><td><code>{{.Policy}}</code></td><td>{{.Category}}</td><td class="num">{{.Status}}</td><td class="num">{{count .Ms}}</td></tr>{{end}}
|
||||
</table>
|
||||
<p class="sub"><code>llmgw ctl request ID</code> says what else a request went through.</p>
|
||||
<p class="sub">{{th "guard.request_hint"}}</p>
|
||||
{{end}}
|
||||
<footer><span>llmgw {{.Version}}</span>{{template "langs"}}</footer>
|
||||
</div></body></html>{{end}}
|
||||
@@ -1,21 +1,21 @@
|
||||
{{define "admin_mirror"}}<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<html lang="{{lang}}"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
{{/* Only while something is downloading. A reload at any other time would
|
||||
take with it whatever the operator was typing into the form below. */}}
|
||||
{{if .RefreshSec}}<meta http-equiv="refresh" content="{{.RefreshSec}}">{{end}}
|
||||
<script defer src="/_static/pages.js"></script>
|
||||
<title>Mirror · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
<title>{{t "mirror.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
</head><body><div class="wrap">
|
||||
<header>
|
||||
<h1>Model mirror</h1>
|
||||
<p class="sub">{{.Addr}}{{if .Unreachable}} did not answer{{end}}</p>
|
||||
<h1>{{t "mirror.heading"}}</h1>
|
||||
<p class="sub">{{.Addr}}{{if .Unreachable}} {{t "mirror.did_not_answer"}}{{end}}</p>
|
||||
{{template "nav" .Nav}}
|
||||
</header>
|
||||
|
||||
{{/* Links rather than a script's panels: each tab has an address, the back
|
||||
button works, and only the one shown asks anything of the registry. */}}
|
||||
<nav class="page-tabs" aria-label="Mirror">
|
||||
<nav class="page-tabs" aria-label="{{t "mirror.tabs_aria"}}">
|
||||
{{range .Tabs}}<a href="{{.Href}}"{{if .Active}} class="on" aria-current="page"{{end}}>{{.Label}}{{with .Count}} <span class="count">{{.}}</span>{{end}}</a>
|
||||
{{end}}</nav>
|
||||
|
||||
@@ -27,50 +27,47 @@
|
||||
{{/* Said outright rather than shown as an empty table. A mirror holding
|
||||
nothing and a mirror that is not there look the same in a list of zero
|
||||
rows, and they call for opposite responses. */}}
|
||||
<p class="note">Nothing was read from the mirror, so this page is not a list of
|
||||
what it holds — it is not currently reachable at <code>{{.Addr}}</code>. Check
|
||||
that <code>llmgw-mirror</code> is running there and that its management port is
|
||||
reachable from this machine.</p>
|
||||
<p class="note">{{th "mirror.unreachable" .Addr}}</p>
|
||||
{{else}}
|
||||
|
||||
<div id="held">
|
||||
{{if .Rows}}
|
||||
<table class="rows compact held">
|
||||
<tr><th>Model</th><th>Files</th><th class="num">Size</th><th>Runs</th><th></th></tr>
|
||||
<tr><th>{{t "mirror.col.model"}}</th><th>{{t "mirror.col.files"}}</th><th class="num">{{t "mirror.col.size"}}</th><th>{{t "mirror.col.runs"}}</th><th></th></tr>
|
||||
{{range .Groups}}{{$grouped := gt .Models 1}}
|
||||
{{if $grouped}}
|
||||
<tr class="model-group"><td colspan="5"><strong>{{.Family}}</strong>
|
||||
<span class="opt">{{.Models}} models · {{bytes .Bytes}}</span>
|
||||
{{if .Fetching}}<span class="tag new">downloading</span>{{end}}
|
||||
{{if .Failed}}<span class="tag bad">failed</span>{{end}}
|
||||
{{if .InUse}}<span class="tag">in use</span>{{end}}</td></tr>
|
||||
<span class="opt">{{tn "mirror.group.models" .Models (bytes .Bytes)}}</span>
|
||||
{{if .Fetching}}<span class="tag new">{{t "mirror.state.fetching"}}</span>{{end}}
|
||||
{{if .Failed}}<span class="tag bad">{{t "mirror.state.failed"}}</span>{{end}}
|
||||
{{if .InUse}}<span class="tag">{{t "mirror.group.in_use"}}</span>{{end}}</td></tr>
|
||||
{{end}}
|
||||
{{range .Rows}}{{$row0 := .}}<tr{{if $grouped}} class="model-version"{{end}}{{if .Detail}} class="open"{{end}}>
|
||||
<td>{{if eq (printf "%s" .State) "present"}}<a class="open-held" aria-expanded="{{if .Detail}}true{{else}}false{{end}}"
|
||||
href="/admin/mirror?tab=held{{if not .Detail}}&open={{.Open}}{{end}}"><strong>{{.Model}}</strong></a>
|
||||
{{else}}<strong>{{.Model}}</strong>{{end}} <span class="opt">{{.Version}}</span>
|
||||
{{if eq .Kind "adapter"}}<span class="tag new" title="a LoRA adapter for {{.Arch}} models: attach it on a model's card">LoRA adapter</span>{{end}}</td>
|
||||
<td class="file" title="{{.File}}{{if .SHA256}} · sha256 {{.SHA256}}{{end}}">
|
||||
{{if gt .Files 1}}{{.Files}} files{{else}}<code>{{.File}}</code>{{end}}</td>
|
||||
{{if eq .Kind "adapter"}}<span class="tag new" title="{{t "mirror.adapter_title" .Arch}}">{{t "mirror.adapter"}}</span>{{end}}</td>
|
||||
<td class="file" title="{{if .SHA256}}{{t "mirror.file_sha" .File .SHA256}}{{else}}{{.File}}{{end}}">
|
||||
{{if gt .Files 1}}{{tn "mirror.files" .Files}}{{else}}<code>{{.File}}</code>{{end}}</td>
|
||||
<td class="num">
|
||||
{{if eq (printf "%s" .State) "fetching"}}
|
||||
{{if .HasPercent}}{{.Percent}}% of {{bytes .BytesTotal}}
|
||||
{{if .HasPercent}}{{t "mirror.percent_of" .Percent (bytes .BytesTotal)}}
|
||||
{{else}}{{/* No total came from upstream, so no percentage is invented —
|
||||
a bar that guesses its own end and then stops moving is worse
|
||||
than a number that only goes up. */}}
|
||||
{{bytes .BytesDone}} so far{{end}}
|
||||
{{t "mirror.so_far" (bytes .BytesDone)}}{{end}}
|
||||
{{else if eq (printf "%s" .State) "failed"}}—
|
||||
{{else}}{{bytes .BytesDone}}{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if eq (printf "%s" .State) "fetching"}}<span class="state state-starting">downloading</span>
|
||||
{{else if eq (printf "%s" .State) "failed"}}<span class="state state-failed">failed</span>
|
||||
{{if eq (printf "%s" .State) "fetching"}}<span class="state state-starting">{{t "mirror.state.fetching"}}</span>
|
||||
{{else if eq (printf "%s" .State) "failed"}}<span class="state state-failed">{{t "mirror.state.failed"}}</span>
|
||||
{{else if .UsedBy}}{{$row := .}}{{range .UsedBy}}<div class="use">
|
||||
{{/* Named only when the catalog calls it something other than the
|
||||
mirror does; otherwise it is the model column again. */}}
|
||||
{{if ne .Name $row.Model}}<span class="as" title="{{.Name}}">as {{.Name}}</span>{{end}}
|
||||
{{if .Declared}}<span class="mode-fixed" title="set in server.yaml, which is read again on every start">
|
||||
{{- if eq .Mode "always"}}always on{{else if eq .Mode "ondemand"}}on demand{{else}}stopped{{end}} · yaml</span>
|
||||
{{if ne .Name $row.Model}}<span class="as" title="{{.Name}}">{{t "mirror.use.as" .Name}}</span>{{end}}
|
||||
{{if .Declared}}<span class="mode-fixed" title="{{t "mirror.use.yaml_title"}}">
|
||||
{{- t "mirror.use.yaml" (t (print "mirror.mode." .Mode))}}</span>
|
||||
{{else}}
|
||||
{{/* Submitted on change: the choice is the action, and a second click
|
||||
to confirm it would only be a way to forget to. */}}
|
||||
@@ -78,22 +75,22 @@ reachable from this machine.</p>
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="model" value="{{.Name}}">
|
||||
<input type="hidden" name="replicas" value="{{.Replicas}}">
|
||||
<select name="mode" class="mode" data-autosubmit aria-label="How {{.Name}} runs">
|
||||
<option value="always"{{if eq .Mode "always"}} selected{{end}}>always on</option>
|
||||
<option value="ondemand"{{if eq .Mode "ondemand"}} selected{{end}}>on demand</option>
|
||||
<option value="stopped"{{if eq .Mode "stopped"}} selected{{end}}>stopped</option>
|
||||
<select name="mode" class="mode" data-autosubmit aria-label="{{t "mirror.use.mode_aria" .Name}}">
|
||||
<option value="always"{{if eq .Mode "always"}} selected{{end}}>{{t "mirror.mode.always"}}</option>
|
||||
<option value="ondemand"{{if eq .Mode "ondemand"}} selected{{end}}>{{t "mirror.mode.ondemand"}}</option>
|
||||
<option value="stopped"{{if eq .Mode "stopped"}} selected{{end}}>{{t "mirror.mode.stopped"}}</option>
|
||||
</select>
|
||||
<noscript><button type="submit" class="link">set</button></noscript>
|
||||
{{/* The button is in the catalogue: what a noscript holds is read as text, not markup. */}}<noscript>{{th "mirror.use.set"}}</noscript>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Fleet}}<span class="runs-now{{if or .Parked .Unplaceable}} bad{{else if lt .Ready .Wanted}} short{{end}}" title="replicas ready / wanted{{if .Starting}}, + starting{{end}}">
|
||||
{{- if .Parked}}parked
|
||||
{{- else if and (eq .Wanted 0) (eq .Ready 0)}}none running
|
||||
{{if .Fleet}}<span class="runs-now{{if or .Parked .Unplaceable}} bad{{else if lt .Ready .Wanted}} short{{end}}" title="{{if .Starting}}{{t "mirror.use.runs_title_starting"}}{{else}}{{t "mirror.use.runs_title"}}{{end}}">
|
||||
{{- if .Parked}}{{t "mirror.use.parked"}}
|
||||
{{- else if and (eq .Wanted 0) (eq .Ready 0)}}{{t "mirror.use.none_running"}}
|
||||
{{- else}}{{.Ready}}/{{.Wanted}}{{if .Starting}} +{{.Starting}}{{end}}{{end}}
|
||||
{{- if .Unplaceable}} · no room{{end}}
|
||||
{{- if .Unplaceable}} · {{t "mirror.use.no_room"}}{{end}}
|
||||
{{- with .Nodes}} · {{range $i, $n := .}}{{if $i}}, {{end}}<span class="node" title="{{$n.Full}}">{{$n.Short}}</span>{{end}}{{end}}</span>{{end}}
|
||||
</div>{{end}}
|
||||
{{else}}<span class="opt">not served</span>{{end}}
|
||||
{{else}}<span class="opt">{{t "mirror.not_served"}}</span>{{end}}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{{if eq (printf "%s" .State) "failed"}}
|
||||
@@ -108,16 +105,16 @@ reachable from this machine.</p>
|
||||
<input type="hidden" name="model" value="{{.Model}}">
|
||||
<input type="hidden" name="version" value="{{.Version}}">
|
||||
<input type="hidden" name="file" value="{{.File}}">
|
||||
<button type="submit" class="link">retry</button>
|
||||
<button type="submit" class="link">{{t "mirror.retry"}}</button>
|
||||
</form>
|
||||
{{else if eq (printf "%s" .State) "present"}}
|
||||
<button type="button" class="link copy-url" data-url="{{.URL}}" title="{{.URL}}">copy address</button>
|
||||
{{if and $.HasCatalog (not .UsedBy)}}{{if .Serve}}<a href="/admin/mirror">close</a>
|
||||
{{else}}<a href="/admin/mirror?serve={{.Model}}:{{.Version}}">serve</a>{{end}}{{end}}
|
||||
<button type="button" class="link copy-url" data-url="{{.URL}}" title="{{.URL}}">{{t "mirror.copy_address"}}</button>
|
||||
{{if and $.HasCatalog (not .UsedBy)}}{{if .Serve}}<a href="/admin/mirror">{{t "mirror.close"}}</a>
|
||||
{{else}}<a href="/admin/mirror?serve={{.Model}}:{{.Version}}">{{t "mirror.serve"}}</a>{{end}}{{end}}
|
||||
<form method="post" action="/admin/mirror/{{.Model}}/{{.Version}}/remove"
|
||||
class="inline" data-confirm="Remove {{.Model}}/{{.Version}} and every file in it?{{if .UsedBy}} It is in use by the catalog.{{end}}">
|
||||
class="inline" data-confirm="{{if .UsedBy}}{{t "mirror.remove_confirm_in_use" .Model .Version}}{{else}}{{t "mirror.remove_confirm" .Model .Version}}{{end}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link danger">remove</button>
|
||||
<button type="submit" class="link danger">{{t "mirror.remove"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
@@ -129,39 +126,38 @@ reachable from this machine.</p>
|
||||
<tr class="editing"><td colspan="5">
|
||||
{{if .Refusal}}
|
||||
{{/* Offered and then failing at placement is worse than not offered at all. */}}
|
||||
<p class="error">Cannot be served as it stands: {{.Refusal}}.</p>
|
||||
<p class="error">{{t "serve.refusal" .Refusal}}</p>
|
||||
{{else}}
|
||||
{{if .Warning}}<p class="note error-row">{{.Warning}}.</p>{{end}}
|
||||
<form method="post" action="/admin/mirror/serve" class="serve-form" data-engine="{{.Engine}}" data-free-gpus="{{.FreeGPUs}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="source" value="{{.Source}}">
|
||||
<input type="hidden" name="engine" value="{{.Engine}}">
|
||||
<p class="sub">Serving <code>{{.Source}}</code> with <strong>{{.Engine}}</strong>.
|
||||
{{if .Files}}The whole version, {{.Files}} files: the node fetches them into one
|
||||
directory and the engine is given the directory.{{end}}</p>
|
||||
<p class="sub">{{th "serve.serving" .Source .Engine}}
|
||||
{{if .Files}}{{t "serve.whole_version" .Files}}{{end}}</p>
|
||||
<div class="field-row">
|
||||
<label>Name <input type="text" name="name" value="{{.Model}}" required></label>
|
||||
<label>Aliases <span class="opt">comma separated</span>
|
||||
<input type="text" name="aliases" placeholder="coder-7b"></label>
|
||||
<label>Replicas <input type="text" name="replicas" value="1" required></label>
|
||||
<label>Runs
|
||||
<label>{{t "serve.name"}} <input type="text" name="name" value="{{.Model}}" required></label>
|
||||
<label>{{t "serve.aliases"}} <span class="opt">{{t "serve.aliases_hint"}}</span>
|
||||
<input type="text" name="aliases" placeholder="{{t "serve.aliases_example"}}"></label>
|
||||
<label>{{t "serve.replicas"}} <input type="text" name="replicas" value="1" required></label>
|
||||
<label>{{t "serve.runs"}}
|
||||
<select name="mode">
|
||||
<option value="always">always on</option>
|
||||
<option value="ondemand">on demand: loads when asked, stops after 15 min unused</option>
|
||||
<option value="always">{{t "mirror.mode.always"}}</option>
|
||||
<option value="ondemand">{{t "serve.mode.ondemand"}}</option>
|
||||
</select></label>
|
||||
</div>
|
||||
{{/* Reaches clients through /v1/models, which for most of them is the only
|
||||
view of what this gateway serves. Nine names and nothing to choose
|
||||
between them is a choice made by guesswork. */}}
|
||||
<label>What it is for <span class="opt">shown to clients in /v1/models</span>
|
||||
<label>{{t "serve.description"}} <span class="opt">{{t "serve.description_hint"}}</span>
|
||||
<input type="text" name="description"
|
||||
placeholder="general chat, strong Swedish; 14B, slower than the 8B"></label>
|
||||
placeholder="{{t "serve.description_example"}}"></label>
|
||||
<div class="field-row">
|
||||
<label>Context a request may use
|
||||
<label>{{t "serve.context_length"}}
|
||||
<input type="text" name="context_length" value="4096" required></label>
|
||||
<label>Output limit <span class="opt">optional</span>
|
||||
<label>{{t "serve.max_output"}} <span class="opt">{{t "serve.optional"}}</span>
|
||||
<input type="text" name="max_output" value="1024"></label>
|
||||
<label>Engine context pool <span class="opt">divided across slots</span>
|
||||
<label>{{t "serve.ctx_pool"}} <span class="opt">{{t "serve.ctx_pool_hint"}}</span>
|
||||
<input type="text" name="ctx_size" value="{{.CtxPool}}"></label>
|
||||
</div>
|
||||
{{/* Where it runs is a choice with consequences, so it is a choice: the
|
||||
@@ -169,56 +165,68 @@ reachable from this machine.</p>
|
||||
field stood here once, and reading as though naming a profile "gpu"
|
||||
would put it on one, it put GGUF models on the CPU of GPU clusters. */}}
|
||||
<div class="field-row">
|
||||
<label>Runs on
|
||||
<label>{{t "serve.placement"}}
|
||||
<select name="placement">
|
||||
{{range .Placements}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select></label>
|
||||
<label>GPUs per replica <span class="opt">on one node</span>
|
||||
<label>{{t "serve.gpus"}} <span class="opt">{{t "serve.gpus_hint"}}</span>
|
||||
<input type="text" name="gpus" value="{{.GPUs}}" size="3"></label>
|
||||
{{if eq .Engine "llamacpp"}}<label>Parallel requests <span class="opt">per replica</span>
|
||||
{{if eq .Engine "llamacpp"}}<label>{{t "serve.parallel"}} <span class="opt">{{t "serve.parallel_hint"}}</span>
|
||||
<input type="text" name="parallel" value="{{.Parallel}}" size="3"></label>{{end}}
|
||||
</div>
|
||||
{{if .FreeGPUs}}<label class="cpu-ok"><input type="checkbox" name="cpu_ok" value="yes">
|
||||
Run on the CPU anyway: {{.FreeGPUs}} GPUs are free and would sit unused</label>{{end}}
|
||||
{{t "serve.cpu_ok" .FreeGPUs}}</label>{{end}}
|
||||
<div class="field-row">
|
||||
<label>Needs RAM (GB) <span class="opt">for the context pool beside it</span>
|
||||
<label>{{t "serve.memory"}} <span class="opt">{{t "serve.memory_hint"}}</span>
|
||||
<input type="text" name="min_memory_gb" value="{{.MinMemoryGB}}" required></label>
|
||||
<label>CPU cores <span class="opt">reserved; the engine's threads</span>
|
||||
<label>{{t "serve.cpu_cores"}} <span class="opt">{{t "serve.cpu_cores_hint"}}</span>
|
||||
<input type="text" name="cpu_cores" value="{{.CPUCores}}"></label>
|
||||
<label>Only nodes labelled <span class="opt">key / value, optional</span>
|
||||
<input type="text" name="selector_key" placeholder="role" size="8">
|
||||
<input type="text" name="selector_value" placeholder="cpu-large" size="10"></label>
|
||||
<label>{{t "serve.selector"}} <span class="opt">{{t "serve.selector_hint"}}</span>
|
||||
<input type="text" name="selector_key" placeholder="{{t "serve.selector_key_example"}}" size="8">
|
||||
<input type="text" name="selector_value" placeholder="{{t "serve.selector_value_example"}}" size="10"></label>
|
||||
</div>
|
||||
<p class="sub serve-summary" hidden></p>
|
||||
<button type="submit">Add to the catalog</button>
|
||||
<button type="submit">{{t "serve.add"}}</button>
|
||||
</form>
|
||||
<script>
|
||||
// What it will run as, in one line, kept up to date as the form changes:
|
||||
// engine, where, how many cards, memory, context and how it is shared.
|
||||
// engine, where, how many cards, memory, context and how it is shared. The
|
||||
// sentences are the ones the server describes a model with, in the page's
|
||||
// language; fmt fills their %s and %d, numbered ones included.
|
||||
(function () {
|
||||
const f = document.currentScript.previousElementSibling;
|
||||
if (!f || !f.classList.contains("serve-form")) return;
|
||||
const T = {
|
||||
cpu: {{t "serve.on_cpu"}}, gpu1: {{t "serve.on_gpu_one"}}, gpus: {{t "serve.on_gpus"}},
|
||||
mem: {{t "serve.host_memory"}}, cores: {{t "serve.cores.other"}},
|
||||
poolPar: {{t "serve.pool_parallel"}}, pool: {{t "serve.pool"}},
|
||||
runAs: {{t "serve.will_run_as"}}, free: {{t "serve.free_gpus_warning"}},
|
||||
};
|
||||
const fmt = (s, ...a) => {
|
||||
let i = 0;
|
||||
return s.replace(/%(?:\[(\d+)\])?[sd]/g, (_, n) => { const k = n ? +n - 1 : i; i = k + 1; return String(a[k]); });
|
||||
};
|
||||
const out = f.querySelector(".serve-summary"), ok = f.querySelector(".cpu-ok");
|
||||
const v = (n) => (f.elements[n] ? f.elements[n].value.trim() : "");
|
||||
const engine = { llamacpp: "llama.cpp", vllm: "vLLM", sglang: "SGLang" }[f.dataset.engine] || f.dataset.engine;
|
||||
function draw() {
|
||||
const place = v("placement"), gpus = parseInt(v("gpus"), 10) || 1;
|
||||
const onCPU = place === "cpu" || place === "";
|
||||
let where = "on the CPU";
|
||||
let where = fmt(T.cpu, engine);
|
||||
if (!onCPU) {
|
||||
const cls = place.replace(/^gpu:/, "");
|
||||
where = gpus > 1 ? "on " + gpus + " " + cls + " GPUs of one node" : "on 1 GPU " + cls;
|
||||
where = gpus > 1 ? fmt(T.gpus, engine, gpus, cls) : fmt(T.gpu1, engine, cls);
|
||||
}
|
||||
const parts = [engine + " " + where];
|
||||
if (v("min_memory_gb")) parts.push(v("min_memory_gb") + " GB of host memory");
|
||||
if (onCPU && v("cpu_cores")) parts.push(v("cpu_cores") + " cores");
|
||||
const parts = [where];
|
||||
if (v("min_memory_gb")) parts.push(fmt(T.mem, v("min_memory_gb")));
|
||||
if (onCPU && v("cpu_cores")) parts.push(fmt(T.cores, v("cpu_cores")));
|
||||
const pool = parseInt(v("ctx_size"), 10), par = parseInt(v("parallel"), 10);
|
||||
if (pool && par) parts.push("context pool " + pool + " over " + par + " parallel requests (" + Math.floor(pool / par) + " each)");
|
||||
else if (pool) parts.push("context pool " + pool);
|
||||
out.textContent = "Will run as: " + parts.join(", ") + ".";
|
||||
if (pool && par) parts.push(fmt(T.poolPar, pool, par, Math.floor(pool / par)));
|
||||
else if (pool) parts.push(fmt(T.pool, pool));
|
||||
out.textContent = fmt(T.runAs, parts.join(", "));
|
||||
const warn = onCPU && +f.dataset.freeGpus > 0;
|
||||
out.classList.toggle("error", warn);
|
||||
if (warn) out.textContent += " " + f.dataset.freeGpus + " GPUs are free; on the CPU this runs many times slower.";
|
||||
if (warn) out.textContent += " " + fmt(T.free, f.dataset.freeGpus);
|
||||
if (ok) ok.hidden = !onCPU;
|
||||
out.hidden = false;
|
||||
}
|
||||
@@ -227,57 +235,45 @@ reachable from this machine.</p>
|
||||
draw();
|
||||
})();
|
||||
</script>
|
||||
<p class="note">The context pool is what the engine is given; llama.cpp divides
|
||||
it across its parallel slots, so the number one request may use is the smaller
|
||||
one above.</p>
|
||||
<p class="note">Memory: {{.MemoryBasis}}. Change the context pool and this changes
|
||||
with it, by the KV cache's share — an estimate, and editable. It is filled in rather than
|
||||
left blank because a CPU profile has no card to be short of: with no figure,
|
||||
every ready node is a candidate and the first one picked is whichever the
|
||||
scheduler reached, not whichever can hold the model. A node label does the same
|
||||
job by name if you would rather pin it.</p>
|
||||
<p class="note">{{t "serve.pool_note"}}</p>
|
||||
<p class="note">{{t "serve.memory_note" .MemoryBasis}}</p>
|
||||
{{end}}
|
||||
</td></tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
<p class="note">“copy address” gives what a catalog entry's <code>source</code>
|
||||
should say — the file, or for a version of several files the directory, with its
|
||||
trailing slash. It is rendered rather than assembled by hand because the mirror
|
||||
and the catalog have to spell a file the same way, which is also how this page
|
||||
knows which models use it.</p>
|
||||
<p class="note">{{th "mirror.copy_note"}}</p>
|
||||
<script>
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.copy-url');
|
||||
if (!b) return;
|
||||
var done = function (t) { var o = b.textContent; b.textContent = t; setTimeout(function () { b.textContent = o; }, 1500); };
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(b.dataset.url).then(function () { done('copied'); },
|
||||
function () { window.prompt('Address', b.dataset.url); });
|
||||
} else { window.prompt('Address', b.dataset.url); }
|
||||
});
|
||||
(function () {
|
||||
var copied = {{t "common.copied"}}, address = {{t "mirror.address_prompt"}};
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.copy-url');
|
||||
if (!b) return;
|
||||
var done = function (t) { var o = b.textContent; b.textContent = t; setTimeout(function () { b.textContent = o; }, 1500); };
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(b.dataset.url).then(function () { done(copied); },
|
||||
function () { window.prompt(address, b.dataset.url); });
|
||||
} else { window.prompt(address, b.dataset.url); }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{else}}<p class="empty">The mirror is reachable and holds nothing yet.
|
||||
{{if .HasRegistry}}<a href="/admin/mirror?tab=explore">Explore Hugging Face</a> for a model, or
|
||||
{{end}}<a href="/admin/mirror?tab=fetch">fetch one by URL</a>.</p>{{end}}
|
||||
{{else}}<p class="empty">{{if .HasRegistry}}{{th "mirror.empty_explore"}}{{else}}{{th "mirror.empty"}}{{end}}</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if and (eq .Tab "explore") .HasRegistry}}
|
||||
{{if not .Repo}}{{with .Explore}}
|
||||
<nav class="pills" aria-label="Which models">{{range .Views}}<a href="{{.Href}}"{{if .Active}} class="on" aria-current="page"{{end}}>{{.Label}}</a>{{end}}</nav>
|
||||
<nav class="pills" aria-label="{{t "explore.views_aria"}}">{{range .Views}}<a href="{{.Href}}"{{if .Active}} class="on" aria-current="page"{{end}}>{{.Label}}</a>{{end}}</nav>
|
||||
{{if eq .View "adapters"}}
|
||||
<p class="note">{{if .Of}}LoRA adapters made for <code>{{.Of}}</code> ·
|
||||
<a href="/admin/mirror?tab=explore&view=adapters">all adapters</a>{{else}}LoRA adapters for any model.
|
||||
From a held model's card, <em>find adapters</em> lists those made for it.{{end}}
|
||||
An adapter runs on the model it was made for, and only as a GGUF adapter file;
|
||||
open one to see which of its files are.</p>
|
||||
<p class="note">{{if .Of}}{{th "explore.adapters_for" .Of}}
|
||||
<a href="/admin/mirror?tab=explore&view=adapters">{{t "explore.all_adapters"}}</a>{{else}}{{th "explore.adapters_any"}}{{end}}
|
||||
{{t "explore.adapters_note"}}</p>
|
||||
{{else}}
|
||||
<div class="explore-filters">
|
||||
<nav class="pills small" aria-label="Task">{{range .Tasks}}<a href="{{.Href}}"{{if .Active}} class="on" aria-current="true"{{end}}>{{.Label}}</a>{{end}}</nav>
|
||||
<nav class="pills small" aria-label="Size">{{range .Sizes}}<a href="{{.Href}}"{{if .Active}} class="on" aria-current="true"{{end}}>{{.Label}}</a>{{end}}</nav>
|
||||
<nav class="pills small" aria-label="{{t "explore.task_aria"}}">{{range .Tasks}}<a href="{{.Href}}"{{if .Active}} class="on" aria-current="true"{{end}}>{{.Label}}</a>{{end}}</nav>
|
||||
<nav class="pills small" aria-label="{{t "explore.size_aria"}}">{{range .Sizes}}<a href="{{.Href}}"{{if .Active}} class="on" aria-current="true"{{end}}>{{.Label}}</a>{{end}}</nav>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -288,9 +284,9 @@ document.addEventListener('click', function (e) {
|
||||
<input type="hidden" name="size" value="{{.Size}}">
|
||||
{{if .Of}}<input type="hidden" name="of" value="{{.Of}}">{{end}}
|
||||
<input type="hidden" name="x" value="1">{{end}}
|
||||
<input type="search" name="q" value="{{.Query}}" placeholder="Search Hugging Face — qwen coder, gemma, bge…" aria-label="Search Hugging Face">
|
||||
<label class="check"><input type="checkbox" name="here" value="1" data-autosubmit{{if .Explore}}{{if .Explore.Here}} checked{{end}}{{end}}> only what these nodes can run</label>
|
||||
<button type="submit">Search</button>
|
||||
<input type="search" name="q" value="{{.Query}}" placeholder="{{t "explore.search_placeholder"}}" aria-label="{{t "explore.search_aria"}}">
|
||||
<label class="check"><input type="checkbox" name="here" value="1" data-autosubmit{{if .Explore}}{{if .Explore.Here}} checked{{end}}{{end}}> {{t "explore.only_here"}}</label>
|
||||
<button type="submit">{{t "explore.search"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
@@ -298,24 +294,24 @@ document.addEventListener('click', function (e) {
|
||||
{{/* Its own line, not the page's error: the registry is somebody else's server
|
||||
and it being down says nothing about the mirror, which is what this page is
|
||||
for and has to keep working when Hugging Face does not. */}}
|
||||
<p class="note error-row">The registry: {{.RegistryErr}}</p>
|
||||
<p class="note error-row">{{t "explore.registry_err" .RegistryErr}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if not .Repo}}{{with .Explore}}
|
||||
{{if .Rows}}
|
||||
<table class="rows explore">
|
||||
<tr><th>Repository</th><th class="num">Size</th><th>Architecture</th><th class="num">Context</th>
|
||||
<th class="num">Downloads</th><th class="num">Likes</th><th class="num">Updated</th></tr>
|
||||
<tr><th>{{t "explore.col.repo"}}</th><th class="num">{{t "explore.col.size"}}</th><th>{{t "explore.col.arch"}}</th><th class="num">{{t "explore.col.context"}}</th>
|
||||
<th class="num">{{t "explore.col.downloads"}}</th><th class="num">{{t "explore.col.likes"}}</th><th class="num">{{t "explore.col.updated"}}</th></tr>
|
||||
{{range .Rows}}<tr class="explore-row">
|
||||
<td><a href="{{.Href}}" class="open-repo" data-repo="{{.ID}}" aria-expanded="false"><code>{{.ID}}</code></a>
|
||||
{{if .New}}<span class="tag new">new</span>{{end}}{{if .Gated.Is}}<span class="tag">gated</span>{{end}}
|
||||
{{if or .BaseModel .License}}<span class="from" title="{{with .BaseModel}}from {{.}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}}">{{with .BaseModel}}from {{.}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}}</span>{{end}}</td>
|
||||
{{if .New}}<span class="tag new">{{t "explore.new"}}</span>{{end}}{{if .Gated.Is}}<span class="tag">{{t "explore.gated"}}</span>{{end}}
|
||||
{{if or .BaseModel .License}}<span class="from" title="{{with .BaseModel}}{{t "explore.from" .}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}}">{{with .BaseModel}}{{t "explore.from" .}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}}</span>{{end}}</td>
|
||||
<td class="num">{{.ParamsLabel}}</td>
|
||||
<td>{{if .Architecture}}<span{{if not .Verdict.Servable}} class="cannot"{{end}} title="{{.Verdict.Reason}}">{{.Architecture}}{{if .Verdict.Checked}}{{if .Verdict.Servable}} ✓{{else}} ✗{{end}}{{end}}</span>{{else}}—{{end}}</td>
|
||||
<td class="num">{{.ContextLabel}}</td>
|
||||
<td class="num">{{compact .Downloads}}</td>
|
||||
<td class="num">{{compact .Likes}}</td>
|
||||
<td class="num">{{.Age}}</td>
|
||||
<td class="num">{{.Updated}}</td>
|
||||
</tr>{{end}}
|
||||
</table>
|
||||
<script>
|
||||
@@ -323,6 +319,12 @@ document.addEventListener('click', function (e) {
|
||||
// list is what somebody is choosing from, and losing it to read one card
|
||||
// means finding their place again for the next. The link still goes to the
|
||||
// repository's own page when this does not run, or with a modifier key.
|
||||
const exploreText = {
|
||||
loading: {{t "explore.js.loading"}}, loadFailed: {{t "explore.js.load_failed"}},
|
||||
starting: {{t "explore.js.starting"}}, refused: {{t "explore.js.refused"}},
|
||||
follow: {{t "explore.js.follow"}}, started: {{t "explore.js.started"}},
|
||||
unreachable: {{t "explore.js.unreachable"}},
|
||||
};
|
||||
document.querySelector("table.explore").addEventListener("click", async function (e) {
|
||||
const a = e.target.closest("a.open-repo");
|
||||
if (!a || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
@@ -339,7 +341,7 @@ document.querySelector("table.explore").addEventListener("click", async function
|
||||
detail.className = "repo-detail";
|
||||
const td = document.createElement("td");
|
||||
td.colSpan = row.children.length;
|
||||
td.textContent = "Loading…";
|
||||
td.textContent = exploreText.loading;
|
||||
detail.appendChild(td);
|
||||
row.after(detail);
|
||||
row.classList.add("open");
|
||||
@@ -352,7 +354,7 @@ document.querySelector("table.explore").addEventListener("click", async function
|
||||
// stripped of anything that runs: the same HTML the full page shows.
|
||||
td.innerHTML = text;
|
||||
} catch (err) {
|
||||
td.textContent = "Could not load it: " + err;
|
||||
td.textContent = exploreText.loadFailed + " " + err;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -367,7 +369,7 @@ document.querySelector("table.explore").addEventListener("submit", async functio
|
||||
const btn = form.querySelector("button[type=submit]");
|
||||
const was = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Starting…";
|
||||
btn.textContent = exploreText.starting;
|
||||
let note = form.parentElement.querySelector(".fetch-note");
|
||||
if (!note) {
|
||||
note = document.createElement("div");
|
||||
@@ -381,7 +383,7 @@ document.querySelector("table.explore").addEventListener("submit", async functio
|
||||
});
|
||||
const out = await resp.json().catch(() => ({ error: "(" + resp.status + ")" }));
|
||||
if (!resp.ok) {
|
||||
note.textContent = out.error || "refused";
|
||||
note.textContent = out.error || exploreText.refused;
|
||||
note.classList.add("bad");
|
||||
btn.disabled = false;
|
||||
btn.textContent = was;
|
||||
@@ -391,25 +393,23 @@ document.querySelector("table.explore").addEventListener("submit", async functio
|
||||
note.textContent = "✓ " + out.message + " — ";
|
||||
const link = document.createElement("a");
|
||||
link.href = "/admin/mirror?tab=held";
|
||||
link.textContent = "follow it under Held";
|
||||
link.textContent = exploreText.follow;
|
||||
note.appendChild(link);
|
||||
btn.textContent = "Started";
|
||||
btn.textContent = exploreText.started;
|
||||
} catch (err) {
|
||||
note.textContent = "Could not reach the gateway: " + err;
|
||||
note.textContent = exploreText.unreachable + " " + err;
|
||||
note.classList.add("bad");
|
||||
btn.disabled = false;
|
||||
btn.textContent = was;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{if .Hidden}}<p class="note">{{.Hidden}} more {{if eq .Hidden 1}}is{{else}}are{{end}} hidden: their architecture is one the nodes' llama.cpp cannot load.
|
||||
Untick “only what these nodes can run” to see them.</p>{{end}}
|
||||
<p class="note">A gated repository needs its terms accepted with a Hugging Face
|
||||
account, and this browses anonymously — the files will list, and fetching one
|
||||
will fail at the mirror. Download it by hand and put it in the mirror
|
||||
directory, or give the mirror a token.</p>
|
||||
{{if .Hidden}}<p class="note">{{tn "explore.hidden" .Hidden}}
|
||||
{{t "explore.untick"}}</p>{{end}}
|
||||
<p class="note">{{t "explore.gated_note"}}</p>
|
||||
{{else if not $.RegistryErr}}
|
||||
<p class="empty">{{if $.Query}}Nothing matched “{{$.Query}}”{{else}}Nothing to list{{end}}{{if .Hidden}} that these nodes can run — {{.Hidden}} {{if eq .Hidden 1}}result was{{else}}results were{{end}} hidden for an architecture llama.cpp cannot load{{end}}.</p>
|
||||
<p class="empty">{{if $.Query}}{{if .Hidden}}{{tn "explore.empty.query_hidden" .Hidden $.Query}}{{else}}{{t "explore.empty.query" $.Query}}{{end}}
|
||||
{{- else}}{{if .Hidden}}{{tn "explore.empty.hidden" .Hidden}}{{else}}{{t "explore.empty.nothing"}}{{end}}{{end}}</p>
|
||||
{{end}}
|
||||
{{end}}{{end}}
|
||||
|
||||
@@ -417,65 +417,61 @@ directory, or give the mirror a token.</p>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Tab "fetch"}}
|
||||
<p class="note">By hand, for anything the search does not reach.</p>
|
||||
<p class="note">{{t "mirror.fetch.intro"}}</p>
|
||||
<form method="post" action="/admin/mirror/fetch">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="tab" value="fetch">
|
||||
<label>Source URL
|
||||
<label>{{t "mirror.fetch.source"}}
|
||||
<input type="text" name="source" required
|
||||
placeholder="https://huggingface.co/…/resolve/main/model.gguf"></label>
|
||||
placeholder="{{t "mirror.fetch.source_example"}}"></label>
|
||||
<div class="field-row">
|
||||
<label>Model <input type="text" name="model" required placeholder="qwen2.5-coder-7b"></label>
|
||||
<label>Version <input type="text" name="version" required placeholder="2026-09"></label>
|
||||
<label>File <span class="opt">optional</span>
|
||||
<input type="text" name="file" placeholder="taken from the URL"></label>
|
||||
<label>{{t "mirror.fetch.model"}} <input type="text" name="model" required placeholder="{{t "mirror.fetch.model_example"}}"></label>
|
||||
<label>{{t "mirror.fetch.version"}} <input type="text" name="version" required placeholder="2026-09"></label>
|
||||
<label>{{t "mirror.fetch.file"}} <span class="opt">{{t "serve.optional"}}</span>
|
||||
<input type="text" name="file" placeholder="{{t "mirror.fetch.file_hint"}}"></label>
|
||||
</div>
|
||||
<label class="inline-check"><input type="checkbox" name="force" value="1">
|
||||
Fetch anyway <span class="opt">even if the nodes' llama.cpp cannot load it</span></label>
|
||||
<button type="submit">Start download</button>
|
||||
{{t "mirror.fetch.force"}} <span class="opt">{{t "mirror.fetch.force_hint"}}</span></label>
|
||||
<button type="submit">{{t "mirror.fetch.start"}}</button>
|
||||
</form>
|
||||
<p class="note">The mirror answers as soon as the download is under way, not when
|
||||
it finishes — a model is tens of gigabytes and nothing here holds a connection
|
||||
open for that. The Held tab shows it arriving. A version is never
|
||||
overwritten: fetching into one that exists is refused, so a new build is a new
|
||||
version and the old weights stay where they are.</p>
|
||||
<p class="note">{{t "mirror.fetch.note"}}</p>
|
||||
{{end}}
|
||||
|
||||
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
|
||||
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
|
||||
</div></body></html>{{end}}
|
||||
|
||||
{{/* One repository: its card and its files. The whole page when opened by
|
||||
address, and the row beneath a listing entry when opened from the list. */}}
|
||||
{{define "mirror_repo"}}
|
||||
{{if not .Inline}}<p><a href="{{.Back}}">← back to the list</a></p>
|
||||
<h3>{{.Repo.ID}} <span class="opt">at {{.Repo.ShortRevision}}</span></h3>{{end}}
|
||||
{{if not .Inline}}<p><a href="{{.Back}}">{{t "card.back"}}</a></p>
|
||||
<h3>{{.Repo.ID}} <span class="opt">{{t "card.at" .Repo.ShortRevision}}</span></h3>{{end}}
|
||||
<p class="repo-meta">
|
||||
{{if .Inline}}<span>at <code>{{.Repo.ShortRevision}}</code></span>{{end}}
|
||||
{{with .Repo.BaseModel}}<span>from {{range $i, $b := .}}{{if $i}}, {{end}}<code>{{$b}}</code>{{end}}</span>{{end}}
|
||||
{{with .Repo.License}}<span>license {{.}}</span>{{end}}
|
||||
{{if .Inline}}<span>{{th "card.at_code" .Repo.ShortRevision}}</span>{{end}}
|
||||
{{with .Repo.BaseModel}}<span>{{t "card.from"}} {{range $i, $b := .}}{{if $i}}, {{end}}<code>{{$b}}</code>{{end}}</span>{{end}}
|
||||
{{with .Repo.License}}<span>{{t "card.license" .}}</span>{{end}}
|
||||
{{with .Repo.Languages}}<span>{{join .}}</span>{{end}}
|
||||
<span><a href="{{.RepoURL}}" rel="noopener noreferrer">on Hugging Face ↗</a></span>
|
||||
<span><a href="{{.RepoURL}}" rel="noopener noreferrer">{{t "card.on_registry"}}</a></span>
|
||||
</p>
|
||||
{{if .Card}}
|
||||
<details class="model-card" open>
|
||||
<summary>Model card</summary>
|
||||
<summary>{{t "card.model_card"}}</summary>
|
||||
<div class="card-body">{{.Card}}</div>
|
||||
</details>
|
||||
{{end}}
|
||||
{{if .RepoSets}}
|
||||
<table class="rows repo-files">
|
||||
<tr><th>What</th><th class="num">Size</th><th>Published checksum</th><th></th></tr>
|
||||
<tr><th>{{t "card.col.what"}}</th><th class="num">{{t "card.col.size"}}</th><th>{{t "card.col.checksum"}}</th><th></th></tr>
|
||||
{{range .RepoSets}}<tr>
|
||||
<td><code>{{.Name}}</code>
|
||||
{{if .Shards}}<span class="opt">{{.Shards}} shards, fetched together</span>{{end}}
|
||||
{{with .Companions}}<span class="opt" title="{{join .}}">with {{len .}} files the engine reads beside them</span>{{end}}
|
||||
{{if eq .Kind "adapter"}}<span class="tag new" title="a LoRA adapter: it runs on a {{.Arch}} model it was made for, not on its own">LoRA adapter · {{.Arch}}</span>
|
||||
{{else if eq .Kind "model"}}<span class="opt">model{{with .Arch}} · {{.}}{{end}}</span>{{end}}</td>
|
||||
{{if .Shards}}<span class="opt">{{t "card.shards" .Shards}}</span>{{end}}
|
||||
{{with .Companions}}<span class="opt" title="{{join .}}">{{t "card.companions" (len .)}}</span>{{end}}
|
||||
{{if eq .Kind "adapter"}}<span class="tag new" title="{{t "card.adapter_title" .Arch}}">{{t "card.adapter" .Arch}}</span>
|
||||
{{else if eq .Kind "model"}}<span class="opt">{{if .Arch}}{{t "card.model_arch" .Arch}}{{else}}{{t "card.model"}}{{end}}</span>{{end}}</td>
|
||||
<td class="num">{{bytes .Bytes}}</td>
|
||||
<td>{{with .SHA256}}<code title="{{.}}">{{slice . 0 12}}</code>{{else}}—{{end}}</td>
|
||||
<td>
|
||||
{{if not .Verdict.Servable}}
|
||||
<span class="state state-failed" title="{{.Verdict.Reason}}">cannot be served</span>
|
||||
<span class="state state-failed" title="{{.Verdict.Reason}}">{{t "card.cannot_serve"}}</span>
|
||||
<span class="clip-inline" title="{{.Verdict.Reason}}">{{.Verdict.Reason}}</span>
|
||||
{{else}}
|
||||
{{if .Verdict.Architecture}}<span class="opt" title="{{.Verdict.Reason}}">{{.Verdict.Architecture}}{{if .Verdict.Checked}} ✓{{end}}</span>{{end}}
|
||||
@@ -487,24 +483,16 @@ version and the old weights stay where they are.</p>
|
||||
{{end}}
|
||||
<input type="text" name="model" value="{{.Model}}" size="24">
|
||||
<input type="text" name="version" value="{{.Version}}" size="10">
|
||||
<button type="submit">Fetch</button>
|
||||
<button type="submit">{{t "card.fetch"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>{{end}}
|
||||
</table>
|
||||
<p class="note">Only the weights are listed; a repository is mostly a README and
|
||||
a tokenizer. A safetensors set is fetched with the files vLLM reads beside it —
|
||||
config.json, the tokenizer, the shard index — and never with code. Shards of one file are one row and one button — a fifteen-shard
|
||||
model is one decision, not fifteen. Two separate files are two rows because they
|
||||
are usually alternatives: one quantisation or the other, not both.</p>
|
||||
<p class="note">The source URLs are pinned to
|
||||
<code>{{.Repo.ShortRevision}}</code> rather than to a branch, so what arrives is
|
||||
what is listed here even if the branch moves in between — and a single file's
|
||||
checksum above is what the registry publishes, to compare with what the mirror
|
||||
computes once it has it.</p>
|
||||
<p class="note">{{t "card.weights_note"}}</p>
|
||||
<p class="note">{{th "card.pinned_note" .Repo.ShortRevision}}</p>
|
||||
{{else}}
|
||||
<p class="empty">No weight files in {{.Repo.ID}}.</p>
|
||||
<p class="empty">{{t "card.no_weights" .Repo.ID}}</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -512,54 +500,52 @@ computes once it has it.</p>
|
||||
{{define "mirror_held_detail"}}{{$row := .Row}}{{$csrf := .CSRF}}{{$scopes := .D.Scopes}}
|
||||
<div class="held-card">
|
||||
<p class="repo-meta">
|
||||
<span>{{if gt $row.Files 1}}{{$row.Files}} files{{else}}<code>{{$row.File}}</code>{{end}}</span>
|
||||
<span>{{if gt $row.Files 1}}{{tn "mirror.files" $row.Files}}{{else}}<code>{{$row.File}}</code>{{end}}</span>
|
||||
<span>{{bytes $row.BytesDone}}</span>
|
||||
{{with $row.SHA256}}<span>sha256 <code title="{{.}}">{{slice . 0 12}}</code></span>{{end}}
|
||||
{{with $row.SHA256}}<span translate="no">sha256 <code title="{{.}}">{{slice . 0 12}}</code></span>{{end}}
|
||||
<span><code>{{$row.URL}}</code></span>
|
||||
</p>
|
||||
{{if not .D.Models}}
|
||||
<p class="note">Not in the catalog: nothing runs this version.
|
||||
{{if $row.Serve}}{{else}}Use <strong>serve</strong> on its row to put it into service.{{end}}</p>
|
||||
<p class="note">{{t "mirror.detail.not_in_catalog"}}
|
||||
{{if $row.Serve}}{{else}}{{th "mirror.detail.use_serve"}}{{end}}</p>
|
||||
{{end}}
|
||||
{{range .D.Models}}
|
||||
<section class="held-model">
|
||||
<h3>{{.Name}}
|
||||
{{with .View}}<span class="opt">{{.Ready}} of {{.Desired}} ready{{if ne .Total .Ready}} · {{.Total}} in all{{end}}</span>
|
||||
{{if .Parked}}<span class="tag bad">parked</span>{{else if .Unplaceable}}<span class="tag bad">does not fit</span>{{end}}{{end}}
|
||||
{{with .View}}<span class="opt">{{t "mirror.detail.ready" .Ready .Desired}}{{if ne .Total .Ready}} · {{t "mirror.detail.in_all" .Total}}{{end}}</span>
|
||||
{{if .Parked}}<span class="tag bad">{{t "mirror.use.parked"}}</span>{{else if .Unplaceable}}<span class="tag bad">{{t "mirror.detail.does_not_fit"}}</span>{{end}}{{end}}
|
||||
</h3>
|
||||
{{with .View.Reason}}<p class="note error-row">{{.}}</p>{{end}}
|
||||
|
||||
{{if .Replicas}}
|
||||
<table class="rows compact replicas">
|
||||
<tr><th>Replica</th><th>Node</th><th>State</th><th class="num">Age</th><th class="num">CPU</th><th class="num">Memory</th></tr>
|
||||
<tr><th>{{t "mirror.replica.col.replica"}}</th><th>{{t "mirror.replica.col.node"}}</th><th>{{t "mirror.replica.col.state"}}</th><th class="num">{{t "mirror.replica.col.age"}}</th><th class="num">CPU</th><th class="num">{{t "mirror.replica.col.memory"}}</th></tr>
|
||||
{{range .Replicas}}{{$rep := .}}{{range $i, $s := .Nodes}}<tr>
|
||||
<td>{{if eq $i 0}}<code>{{$rep.ID}}</code>{{end}}</td>
|
||||
<td><a href="/admin/nodes/{{$s.Node}}">{{$s.Node}}</a>{{if $s.MemoryGB}} <span class="opt">{{$s.MemoryGB}} GB</span>{{end}}</td>
|
||||
<td>{{if eq $i 0}}<span class="state state-{{if eq $rep.State "ready"}}ready{{else if eq $rep.State "failed"}}failed{{else}}starting{{end}}">{{$rep.State}}</span>
|
||||
{{with $rep.Restart}}<span class="opt">restart {{.}}</span>{{end}}{{end}}</td>
|
||||
<td>{{if eq $i 0}}<span class="state state-{{if eq $rep.State "ready"}}ready{{else if eq $rep.State "failed"}}failed{{else}}starting{{end}}">{{$rep.StateText}}</span>
|
||||
{{with $rep.Restart}}<span class="opt">{{t "mirror.replica.restart" .}}</span>{{end}}{{end}}</td>
|
||||
<td class="num">{{if eq $i 0}}{{age $rep.Age}}{{end}}</td>
|
||||
<td class="num">{{if $s.CPU}}{{printf "%.1f" $s.CPU}} cores{{else}}—{{end}}</td>
|
||||
<td class="num">{{if $s.CPU}}{{t "mirror.replica.cores" (num $s.CPU 1)}}{{else}}—{{end}}</td>
|
||||
<td class="num">{{if $s.MemBytes}}{{bytes $s.MemBytes}}{{else}}—{{end}}</td>
|
||||
</tr>{{end}}{{end}}
|
||||
</table>
|
||||
{{else}}<p class="note">No replica runs now{{if eq .Mode "ondemand"}}: it starts when somebody asks for it{{end}}.</p>{{end}}
|
||||
{{else}}<p class="note">{{if eq .Mode "ondemand"}}{{t "mirror.detail.none_running_ondemand"}}{{else}}{{t "mirror.detail.none_running"}}{{end}}</p>{{end}}
|
||||
|
||||
{{with .Headroom}}
|
||||
{{if .Headroom}}
|
||||
<p class="headroom">
|
||||
{{if .Limited}}Room for at least {{.More}} more replicas.
|
||||
{{else if .More}}Room for {{.More}} more now{{if .Nodes}}, on {{range $i, $n := .Nodes}}{{if $i}}, {{end}}{{join $n}}{{end}}{{end}}.
|
||||
{{else}}No free room for another replica now.{{end}}
|
||||
{{if .ByPreempting}}<br><span class="note">{{if .More}}And {{end}}{{.ByPreempting}} {{if .More}}more {{end}}by stopping replicas its class outranks: {{join .Preempts}}.</span>{{end}}
|
||||
{{.RoomText}}
|
||||
{{with .PreemptText}}<br><span class="note">{{.}}</span>{{end}}
|
||||
</p>
|
||||
{{if and (not .Limited) .Blocked}}<details class="blocked"><summary>Why not {{if .More}}more{{else}}another{{end}}</summary>
|
||||
<ul>{{range .Blocked}}<li>{{.}}</li>{{end}}</ul></details>{{end}}
|
||||
{{if and (not .Headroom.Limited) .Headroom.Blocked}}<details class="blocked"><summary>{{if .Headroom.More}}{{t "mirror.room.why_not_more"}}{{else}}{{t "mirror.room.why_not_another"}}{{end}}</summary>
|
||||
<ul>{{range .Headroom.Blocked}}<li>{{.}}</li>{{end}}</ul></details>{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if not .Declared}}{{$mname := .Name}}
|
||||
<div class="adapters">
|
||||
<h4>LoRA adapters <a class="note" href="{{.FindAdapters}}">find adapters on Hugging Face →</a></h4>
|
||||
<h4>{{t "mirror.adapters.heading"}} <a class="note" href="{{.FindAdapters}}">{{t "mirror.adapters.find"}}</a></h4>
|
||||
{{if .Adapters}}<table class="compact rows">
|
||||
<tr><th>Asked for as</th><th>File</th><th class="num">Scale</th><th></th></tr>
|
||||
<tr><th>{{t "mirror.adapters.asked_as"}}</th><th>{{t "mirror.adapters.file"}}</th><th class="num">{{t "mirror.adapters.scale"}}</th><th></th></tr>
|
||||
{{range .Adapters}}{{$a := .}}<tr>
|
||||
<td><code>{{.Name}}</code></td>
|
||||
<td class="clip" title="{{.Source}}">{{.Source}}</td>
|
||||
@@ -570,9 +556,9 @@ computes once it has it.</p>
|
||||
<input type="hidden" name="open" value="{{$row.Open}}">
|
||||
<input type="hidden" name="op" value="remove">
|
||||
<input type="hidden" name="name" value="{{$a.Name}}">
|
||||
<button type="submit" class="link danger">remove</button></form></td>
|
||||
<button type="submit" class="link danger">{{t "mirror.remove"}}</button></form></td>
|
||||
</tr>{{end}}</table>
|
||||
{{else}}<p class="note">None. A request for an adapter's name is served by this model's replicas with it applied.</p>{{end}}
|
||||
{{else}}<p class="note">{{t "mirror.adapters.none"}}</p>{{end}}
|
||||
{{if .AdapterChoices}}{{$m := .}}
|
||||
<form method="post" action="/admin/mirror/adapters" class="adapter-form">
|
||||
<input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
@@ -580,53 +566,50 @@ computes once it has it.</p>
|
||||
<input type="hidden" name="open" value="{{$row.Open}}">
|
||||
<input type="hidden" name="op" value="add">
|
||||
<div class="field-row">
|
||||
<label>Adapter file <select name="source" required data-suggest-into="name">
|
||||
<label>{{t "mirror.adapters.adapter_file"}} <select name="source" required data-suggest-into="name">
|
||||
{{range .AdapterChoices}}<option value="{{.URL}}" data-suggest="{{.Suggest}}">{{.Label}}</option>{{end}}
|
||||
</select></label>
|
||||
<label>Asked for as <input type="text" name="name" value="{{(index .AdapterChoices 0).Suggest}}" required></label>
|
||||
<label>Scale <input type="text" name="scale" value="1" size="4"></label>
|
||||
<button type="submit">Attach</button>
|
||||
<label>{{t "mirror.adapters.asked_as"}} <input type="text" name="name" value="{{(index .AdapterChoices 0).Suggest}}" required></label>
|
||||
<label>{{t "mirror.adapters.scale"}} <input type="text" name="scale" value="1" size="4"></label>
|
||||
<button type="submit">{{t "mirror.adapters.attach"}}</button>
|
||||
</div>
|
||||
</form>{{else}}<p class="note">No held adapter file for this model's architecture. Fetch one from Hugging Face first.</p>{{end}}
|
||||
</form>{{else}}<p class="note">{{t "mirror.adapters.no_choice"}}</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Declared}}
|
||||
<p class="note">Set in server.yaml, which is read again on every start: change its replicas there.</p>
|
||||
<p class="note">{{t "mirror.detail.declared"}}</p>
|
||||
{{else}}
|
||||
<form method="post" action="/admin/mirror/replicas" class="replicas-form">
|
||||
<input type="hidden" name="csrf" value="{{$csrf}}">
|
||||
<input type="hidden" name="model" value="{{.Name}}">
|
||||
<input type="hidden" name="open" value="{{$row.Open}}">
|
||||
<div class="field-row">
|
||||
<label>Always running
|
||||
<label>{{t "mirror.form.min"}}
|
||||
<input type="number" name="min" value="{{.Min}}" min="0"{{if .MostNow}} max="{{.MostNow}}"{{end}} required></label>
|
||||
<label>Up to, under load
|
||||
<label>{{t "mirror.form.max"}}
|
||||
<input type="number" name="max" value="{{.Max}}" min="0" max="64" required></label>
|
||||
<label>Class
|
||||
<label>{{t "mirror.form.class"}}
|
||||
<select name="class">
|
||||
<option value=""{{if eq .Class ""}} selected{{end}}>default ({{if eq .Mode "ondemand"}}opportunistic{{else}}standard{{end}})</option>
|
||||
<option value="critical"{{if eq .Class "critical"}} selected{{end}}>critical</option>
|
||||
<option value="standard"{{if eq .Class "standard"}} selected{{end}}>standard</option>
|
||||
<option value="opportunistic"{{if eq .Class "opportunistic"}} selected{{end}}>opportunistic</option>
|
||||
<option value=""{{if eq .Class ""}} selected{{end}}>{{if eq .Mode "ondemand"}}{{t "mirror.class.default" (t "mirror.class.opportunistic")}}{{else}}{{t "mirror.class.default" (t "mirror.class.standard")}}{{end}}</option>
|
||||
<option value="critical"{{if eq .Class "critical"}} selected{{end}}>{{t "mirror.class.critical"}}</option>
|
||||
<option value="standard"{{if eq .Class "standard"}} selected{{end}}>{{t "mirror.class.standard"}}</option>
|
||||
<option value="opportunistic"{{if eq .Class "opportunistic"}} selected{{end}}>{{t "mirror.class.opportunistic"}}</option>
|
||||
</select></label>
|
||||
{{if $scopes}}{{$owner := .Owner}}<label>Owner
|
||||
{{if $scopes}}{{$owner := .Owner}}<label>{{t "mirror.form.owner"}}
|
||||
<select name="owner">
|
||||
<option value=""{{if eq $owner ""}} selected{{end}}>shared</option>
|
||||
<option value=""{{if eq $owner ""}} selected{{end}}>{{t "mirror.form.shared"}}</option>
|
||||
{{range $scopes}}<option value="{{.Key}}"{{if eq .Key $owner}} selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select></label>{{end}}
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Memory GB <span class="opt">per replica</span>
|
||||
<label>{{t "mirror.form.memory"}} <span class="opt">{{t "mirror.form.per_replica"}}</span>
|
||||
<input type="number" name="memory" value="{{.MemoryGB}}" min="0"></label>
|
||||
<label>Cores <span class="opt">per replica, its threads</span>
|
||||
<label>{{t "mirror.form.cores"}} <span class="opt">{{t "mirror.form.cores_hint"}}</span>
|
||||
<input type="number" name="cores" value="{{.CPUCores}}" min="0"></label>
|
||||
<button type="submit">Save</button>
|
||||
<button type="submit">{{t "common.save"}}</button>
|
||||
</div>
|
||||
<p class="note">{{if .MostNow}}At most {{.MostNow}} can always run now{{if .Headroom}}{{if .Headroom.ByPreempting}}, counting what its class may stop to make room{{end}}{{end}}. {{end}}0 always running with a
|
||||
higher limit loads it when asked for; 0 and 0 stops it. A replica is added under load when requests wait, and
|
||||
taken away after ten quiet minutes. A higher class may stop replicas of lower ones to make room; memory and
|
||||
cores apply to replicas started after the change.</p>
|
||||
<p class="note">{{if .MostNow}}{{if and .Headroom .Headroom.ByPreempting}}{{t "mirror.form.most_now_preempting" .MostNow}}{{else}}{{t "mirror.form.most_now" .MostNow}}{{end}} {{end}}{{t "mirror.form.note"}}</p>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
@@ -3,25 +3,25 @@
|
||||
links work as plain navigation; htmx, when it loads, swaps only #people,
|
||||
so a change does not jump the page. */}}
|
||||
{{define "admin_people"}}<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<html lang="{{lang}}"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>People · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
<title>{{t "people.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
<meta name="htmx-config" content='{"scrollIntoViewOnBoost":false,"defaultFocusScroll":false,"allowEval":false}'>
|
||||
<script src="/_static/htmx-2.0.11.min.js" defer></script>
|
||||
</head><body><div class="wrap">
|
||||
<header>
|
||||
<h1>People</h1>
|
||||
<p class="sub">Organizations, teams, projects and the people in them: what each may use, and what it used</p>
|
||||
<h1>{{t "people.title"}}</h1>
|
||||
<p class="sub">{{t "people.sub"}}</p>
|
||||
{{template "nav" .Nav}}
|
||||
</header>
|
||||
|
||||
<div id="people" hx-boost="true" hx-target="#people" hx-select="#people"
|
||||
hx-swap="outerHTML" hx-push-url="false">
|
||||
|
||||
<nav class="page-tabs" aria-label="People">
|
||||
{{if .HasPeople}}<a href="/admin/users" hx-push-url="true"{{if eq .Tab "people"}} aria-current="page"{{end}}>Organizations & people{{if .Pending}} <span class="tag">{{.Pending}} waiting</span>{{end}}</a>{{end}}
|
||||
{{if .HasLimits}}<a href="/admin/quotas" hx-push-url="true"{{if eq .Tab "limits"}} aria-current="page"{{end}}>Limits</a>{{end}}
|
||||
{{if .HasUsage}}<a href="/admin/usage" hx-push-url="true"{{if eq .Tab "usage"}} aria-current="page"{{end}}>Usage</a>{{end}}
|
||||
<nav class="page-tabs" aria-label="{{t "people.title"}}">
|
||||
{{if .HasPeople}}<a href="/admin/users" hx-push-url="true"{{if eq .Tab "people"}} aria-current="page"{{end}}>{{t "people.tab.people"}}{{if .Pending}} <span class="tag">{{t "people.waiting" .Pending}}</span>{{end}}</a>{{end}}
|
||||
{{if .HasLimits}}<a href="/admin/quotas" hx-push-url="true"{{if eq .Tab "limits"}} aria-current="page"{{end}}>{{t "people.tab.limits"}}</a>{{end}}
|
||||
{{if .HasUsage}}<a href="/admin/usage" hx-push-url="true"{{if eq .Tab "usage"}} aria-current="page"{{end}}>{{t "people.tab.usage"}}</a>{{end}}
|
||||
</nav>
|
||||
|
||||
{{if .Users}}
|
||||
@@ -34,15 +34,15 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
|
||||
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
|
||||
</div>
|
||||
<script>
|
||||
// Search the account list. Outside #people so what was typed survives a swap,
|
||||
// and applied again after every swap.
|
||||
(function () {
|
||||
var box = document.createElement('input');
|
||||
box.type = 'search'; box.placeholder = 'Find a person by email, name or project';
|
||||
box.className = 'search'; box.setAttribute('aria-label', 'Find a person');
|
||||
box.type = 'search'; box.placeholder = {{t "people.search.placeholder"}};
|
||||
box.className = 'search'; box.setAttribute('aria-label', {{t "people.search.label"}});
|
||||
function apply() {
|
||||
var q = box.value.trim().toLowerCase();
|
||||
document.querySelectorAll('#account-list tr[data-q]').forEach(function (tr) {
|
||||
@@ -72,84 +72,84 @@
|
||||
{{/* One branch or person: its limits, its capacity and its use, together. */}}
|
||||
{{define "people-detail"}}{{$d := .}}{{$sel := printf "%s:%s" .Kind .ID}}
|
||||
<div class="pd-head">
|
||||
<span class="q-kind">{{if eq (printf "%s" .Kind) "user"}}person{{else}}{{.Kind}}{{end}}</span>
|
||||
<span class="q-kind">{{t (print "people.kind." .Kind)}}</span>
|
||||
<h2>{{.Name}}</h2>
|
||||
{{if and .Path (ne .Path .Name)}}<p class="pd-path">{{.Path}}</p>{{end}}
|
||||
<a href="/admin/users" class="pd-close" aria-label="Close">×</a>
|
||||
<a href="/admin/users" class="pd-close" aria-label="{{t "people.close"}}">×</a>
|
||||
</div>
|
||||
|
||||
{{with .Memberships}}<p class="pd-sub">In {{range $i, $m := .}}{{if $i}}, {{end}}<a href="/admin/users?sel={{$m.Scope}}:{{$m.ScopeID}}">{{$m.Path}}</a>{{end}}</p>
|
||||
{{else}}{{if eq (printf "%s" $d.Kind) "user"}}<p class="pd-sub">On their own: in no organization, team or project.</p>{{end}}{{end}}
|
||||
{{with .Memberships}}<p class="pd-sub">{{t "people.in"}} {{range $i, $m := .}}{{if $i}}, {{end}}<a href="/admin/users?sel={{$m.Scope}}:{{$m.ScopeID}}">{{$m.Path}}</a>{{end}}</p>
|
||||
{{else}}{{if eq (printf "%s" $d.Kind) "user"}}<p class="pd-sub">{{t "people.on_own"}}</p>{{end}}{{end}}
|
||||
|
||||
{{if or .Limits .Person}}
|
||||
<h3>Limits</h3>
|
||||
{{if not .BudgetsBind}}<p class="note">No model has a price, so budgets stay at zero; rate and token limits apply.</p>{{end}}
|
||||
<h3>{{t "people.limits"}}</h3>
|
||||
{{if not .BudgetsBind}}<p class="note">{{t "people.budgets_unpriced"}}</p>{{end}}
|
||||
{{with .Limits}}
|
||||
<dl class="pd-limits">
|
||||
<dt>Requests/min</dt><dd>{{template "quota-value" dict "Own" .RPM "Up" .RPMUp "From" .RPMFrom}}</dd>
|
||||
<dt>Tokens/min</dt><dd>{{template "quota-value" dict "Own" .TPM "Up" .TPMUp "From" .TPMFrom}}</dd>
|
||||
<dt>Budget</dt><dd>{{if .Budget}}{{.Budget}}{{if .Period}}<span class="q-per">/{{.Period}}</span>{{end}}{{else}}{{template "quota-value" dict "Own" "" "Up" .BudgetUp "From" .BudgetFrom}}{{end}}</dd>
|
||||
<dt>Spent, {{$d.QuotaWindow}}</dt><dd><span class="q-spend">{{.Spend}}</span>{{if .HasPct}} <span class="q-meter{{if ge .Percent 90}} hot{{end}}"><i style="width:{{pct .Percent 100}}%"></i></span> <span class="q-pct">{{.Percent}}%</span>{{end}}</dd>
|
||||
<dt>Models</dt><dd>{{if .Models}}only <code>{{.Models}}</code>{{else if .ModelsFrom}}limited by {{.ModelsFrom}}{{else}}every model{{end}}</dd>
|
||||
{{if .Member}}<dt>Each member</dt><dd>{{.Member}}</dd>{{end}}
|
||||
<dt>{{t "people.rpm"}}</dt><dd>{{template "quota-value" dict "Own" .Shown.RPM "Up" .RPMUp "From" .RPMFrom}}</dd>
|
||||
<dt>{{t "people.tpm"}}</dt><dd>{{template "quota-value" dict "Own" .Shown.TPM "Up" .TPMUp "From" .TPMFrom}}</dd>
|
||||
<dt>{{t "people.budget"}}</dt><dd>{{if .Budget}}{{.Shown.Budget}}{{if .Period}}<span class="q-per">/{{.Shown.Period}}</span>{{end}}{{else}}{{template "quota-value" dict "Up" .BudgetUp "From" .BudgetFrom}}{{end}}</dd>
|
||||
<dt>{{t "people.spent" $d.QuotaWindow}}</dt><dd><span class="q-spend">{{.Spend}}</span>{{if .HasPct}} <span class="q-meter{{if ge .Percent 90}} hot{{end}}"><i style="width:{{pct .Percent 100}}%"></i></span> <span class="q-pct">{{t "quotas.pct" .Percent}}</span>{{end}}</dd>
|
||||
<dt>{{t "people.models"}}</dt><dd>{{if .Models}}{{th "people.models_only" .Models}}{{else if .ModelsFrom}}{{t "people.models_limited_by" .ModelsFrom}}{{else}}{{t "people.models_every"}}{{end}}</dd>
|
||||
{{if .Member}}<dt>{{t "people.each_member"}}</dt><dd>{{.Member}}</dd>{{end}}
|
||||
</dl>
|
||||
{{if .Shadowed}}<p class="q-sub q-warn">{{.Shadowed}}</p>{{end}}
|
||||
<details class="edit"><summary>{{if .HasAny}}change{{else}}set{{end}} limits</summary>
|
||||
<details class="edit"><summary>{{if .HasAny}}{{t "people.change_limits"}}{{else}}{{t "people.set_limits"}}{{end}}</summary>
|
||||
{{template "quota-form" (dict "Scope" (printf "%s" .Scope) "ID" .ID "CSRF" $d.CSRF "Sel" $sel
|
||||
"F" (dict "RPM" .RPM "TPM" .TPM "Budget" .Budget "Period" .Period "Models" .Models "HasAny" .HasAny)
|
||||
"Title" (printf "Quota for %s" .Crumb)
|
||||
"Note" "Shared by everything beneath it.")}}
|
||||
"Title" (t "people.quota_for" .Crumb)
|
||||
"Note" (t "people.note.shared"))}}
|
||||
</details>
|
||||
<details class="edit"><summary>limit each member</summary>
|
||||
<details class="edit"><summary>{{t "people.limit_each_member"}}</summary>
|
||||
{{template "quota-form" (dict "Scope" (printf "%s-member" .Scope) "ID" .ID "F" .MemberRaw "CSRF" $d.CSRF "Sel" $sel
|
||||
"Title" (printf "Each member of %s, separately" .Crumb)
|
||||
"Note" "Every member gets this much here, counted apart from the others.")}}
|
||||
"Title" (t "people.each_member_of" .Crumb)
|
||||
"Note" (t "people.note.each_member"))}}
|
||||
</details>
|
||||
{{end}}
|
||||
{{with .Person}}
|
||||
<dl class="pd-limits">
|
||||
<dt>Requests/min</dt><dd>{{or .RPM "—"}}</dd>
|
||||
<dt>Tokens/min</dt><dd>{{or .TPM "—"}}</dd>
|
||||
<dt>Budget</dt><dd>{{if .Budget}}{{.Budget}}{{if .Period}}<span class="q-per">/{{.Period}}</span>{{end}}{{else}}—{{end}}</dd>
|
||||
<dt>Spent, {{$d.QuotaWindow}}</dt><dd><span class="q-spend">{{.Spend}}</span>{{if .HasPct}} <span class="q-meter{{if ge .Percent 90}} hot{{end}}"><i style="width:{{pct .Percent 100}}%"></i></span> <span class="q-pct">{{.Percent}}%</span>{{end}}</dd>
|
||||
<dt>Models</dt><dd>{{if .Models}}only <code>{{.Models}}</code>{{else}}every model{{end}}</dd>
|
||||
<dt>{{t "people.rpm"}}</dt><dd>{{or .Shown.RPM "—"}}</dd>
|
||||
<dt>{{t "people.tpm"}}</dt><dd>{{or .Shown.TPM "—"}}</dd>
|
||||
<dt>{{t "people.budget"}}</dt><dd>{{if .Budget}}{{.Shown.Budget}}{{if .Period}}<span class="q-per">/{{.Shown.Period}}</span>{{end}}{{else}}—{{end}}</dd>
|
||||
<dt>{{t "people.spent" $d.QuotaWindow}}</dt><dd><span class="q-spend">{{.Spend}}</span>{{if .HasPct}} <span class="q-meter{{if ge .Percent 90}} hot{{end}}"><i style="width:{{pct .Percent 100}}%"></i></span> <span class="q-pct">{{t "quotas.pct" .Percent}}</span>{{end}}</dd>
|
||||
<dt>{{t "people.models"}}</dt><dd>{{if .Models}}{{th "people.models_only" .Models}}{{else}}{{t "people.models_every"}}{{end}}</dd>
|
||||
</dl>
|
||||
<details class="edit"><summary>{{if .HasAny}}change{{else}}set{{end}} limits</summary>
|
||||
<details class="edit"><summary>{{if .HasAny}}{{t "people.change_limits"}}{{else}}{{t "people.set_limits"}}{{end}}</summary>
|
||||
{{template "quota-form" (dict "Scope" "user" "ID" .ID "CSRF" $d.CSRF "Sel" $sel
|
||||
"F" (dict "RPM" .RPM "TPM" .TPM "Budget" .Budget "Period" .Period "Models" .Models "HasAny" .HasAny)
|
||||
"Title" (printf "%s, everywhere" .Email)
|
||||
"Note" "Across every key this person holds, every project they use and the chat page.")}}
|
||||
"Title" (t "people.everywhere" .Email)
|
||||
"Note" (t "people.note.person"))}}
|
||||
</details>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .HasCapacity}}
|
||||
<h3>Capacity</h3>
|
||||
<h3>{{t "people.capacity"}}</h3>
|
||||
{{with .Capacity}}
|
||||
<dl class="pd-limits">
|
||||
<dt>GPUs</dt><dd>{{or .GPUs "—"}}</dd>
|
||||
<dt>Cores</dt><dd>{{or .Cores "—"}}</dd>
|
||||
<dt>Memory GB</dt><dd>{{or .Memory "—"}}</dd>
|
||||
<dt>{{t "people.cores"}}</dt><dd>{{or .Cores "—"}}</dd>
|
||||
<dt>{{t "people.memory_gb"}}</dt><dd>{{or .Memory "—"}}</dd>
|
||||
</dl>
|
||||
<p class="note">Held of guaranteed (at most the limit). <a href="/admin/quotas?cap={{$sel}}#capacity">change</a></p>
|
||||
{{else}}<p class="note">No capacity quota: its models share the cluster by their class alone.
|
||||
<a href="/admin/quotas?cap={{$sel}}#capacity">set one</a></p>{{end}}
|
||||
<p class="note">{{t "people.capacity_note"}} <a href="/admin/quotas?cap={{$sel}}#capacity">{{t "people.change"}}</a></p>
|
||||
{{else}}<p class="note">{{t "people.no_capacity"}}
|
||||
<a href="/admin/quotas?cap={{$sel}}#capacity">{{t "people.set_one"}}</a></p>{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .HasUsage}}
|
||||
<h3>Use, {{.UsageWindow}}</h3>
|
||||
<h3>{{t "people.use" .UsageWindow}}</h3>
|
||||
{{if .Totals.Name}}
|
||||
<p class="pd-sub">{{compact .Totals.Requests}} requests · {{compact .Totals.Tokens}} tokens{{if .Priced}} · {{.Totals.Spend}} spent{{end}}{{if .Totals.Errors}} · {{.Totals.Errors}} errors{{end}}</p>
|
||||
<p class="pd-sub">{{t "people.n_requests" (compact .Totals.Requests)}} · {{t "people.n_tokens" (compact .Totals.Tokens)}}{{if .Priced}} · {{t "people.spent_amount" .Totals.Spend}}{{end}}{{if .Totals.Errors}} · {{t "people.n_errors" (count .Totals.Errors)}}{{end}}</p>
|
||||
{{if and .HasStats .Stats.Requests}}{{with .Stats}}
|
||||
<dl class="pd-limits">
|
||||
<dt>Error rate</dt><dd{{if ge .ErrorRate 5.0}} class="q-warn"{{end}}>{{pct1 .ErrorRate}}%</dd>
|
||||
<dt>Refused by a limit</dt><dd{{if .Limited}} class="q-warn"{{end}}>{{if .Limited}}{{compact .Limited}}{{else}}none{{end}}</dd>
|
||||
<dt>First token</dt><dd>p50 {{pctile .TTFT 50}} · p95 {{pctile .TTFT 95}} · p99 {{pctile .TTFT 99}}</dd>
|
||||
<dt>Latency</dt><dd>p50 {{pctile .Latency 50}} · p95 {{pctile .Latency 95}} · p99 {{pctile .Latency 99}}</dd>
|
||||
<dt>{{t "people.error_rate"}}</dt><dd{{if ge .ErrorRate 5.0}} class="q-warn"{{end}}>{{pct1 .ErrorRate}}%</dd>
|
||||
<dt>{{t "people.refused"}}</dt><dd{{if .Limited}} class="q-warn"{{end}}>{{if .Limited}}{{compact .Limited}}{{else}}{{t "common.none"}}{{end}}</dd>
|
||||
<dt>{{t "people.first_token"}}</dt><dd>p50 {{pctile .TTFT 50}} · p95 {{pctile .TTFT 95}} · p99 {{pctile .TTFT 99}}</dd>
|
||||
<dt>{{t "people.latency"}}</dt><dd>p50 {{pctile .Latency 50}} · p95 {{pctile .Latency 95}} · p99 {{pctile .Latency 99}}</dd>
|
||||
</dl>{{end}}{{end}}
|
||||
{{template "tschart" dict "t" .Requests "title" "Requests" "note" ""}}
|
||||
{{template "tschart" dict "t" .Tokens "title" "Tokens" "note" ""}}
|
||||
{{if .Priced}}{{template "tschart" dict "t" .Spend "title" "Spend" "note" ""}}{{end}}
|
||||
{{else}}<p class="empty">Nothing used in this window.</p>{{end}}
|
||||
{{template "tschart" dict "t" .Requests "title" (t "people.chart.requests") "note" ""}}
|
||||
{{template "tschart" dict "t" .Tokens "title" (t "people.chart.tokens") "note" ""}}
|
||||
{{if .Priced}}{{template "tschart" dict "t" .Spend "title" (t "people.chart.spend") "note" ""}}{{end}}
|
||||
{{else}}<p class="empty">{{t "people.nothing_used"}}</p>{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -6,10 +6,7 @@
|
||||
prices every request costs nothing, so these numbers read as limits and
|
||||
bind on nothing. Self-hosted models having no per-token price is ordinary;
|
||||
showing somebody a budget without saying so is not. */}}
|
||||
<p class="banner-warn" role="note">No model has a price, so nothing is charged and
|
||||
budgets stay at zero however much is used. Rate and token limits still apply. Set
|
||||
prices in the <code>pricing</code> block of the server configuration to make
|
||||
budgets bind.</p>
|
||||
<p class="banner-warn" role="note">{{th "quotas.unpriced"}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
||||
@@ -20,42 +17,42 @@ budgets bind.</p>
|
||||
one opens on that branch rather than asking somebody to name it again in a
|
||||
box at the bottom. */}}
|
||||
<table class="rows quota-tree">
|
||||
<tr><th>Where</th><th class="num">Requests/min</th><th class="num">Tokens/min</th>
|
||||
<th class="num">Budget</th><th>Spent</th><th></th></tr>
|
||||
<tr><th>{{t "quotas.col.where"}}</th><th class="num">{{t "quotas.col.rpm"}}</th><th class="num">{{t "quotas.col.tpm"}}</th>
|
||||
<th class="num">{{t "quotas.col.budget"}}</th><th>{{t "quotas.col.spent"}}</th><th></th></tr>
|
||||
{{range .Rows}}
|
||||
<tr class="q-depth-{{.Indent}}{{if .Editing}} editing{{end}}">
|
||||
<td class="q-where">
|
||||
<span class="q-name">{{.Path}}</span> <span class="q-kind">{{.Scope}}</span>
|
||||
{{if .Models}}<div class="q-sub">only <code>{{.Models}}</code></div>
|
||||
{{else if .ModelsFrom}}<div class="q-sub">models limited by {{.ModelsFrom}}</div>{{end}}
|
||||
{{if .Member}}<div class="q-sub">each member here: {{.Member}}</div>{{end}}
|
||||
<span class="q-name">{{.Path}}</span> <span class="q-kind">{{t (print "quotas.kind." .Scope)}}</span>
|
||||
{{if .Models}}<div class="q-sub">{{th "quotas.only_models" .Models}}</div>
|
||||
{{else if .ModelsFrom}}<div class="q-sub">{{t "quotas.models_from" .ModelsFrom}}</div>{{end}}
|
||||
{{if .Member}}<div class="q-sub">{{t "quotas.each_member" .Member}}</div>{{end}}
|
||||
{{if .Shadowed}}
|
||||
{{/* A value that is written here and never reached. Resolution takes the
|
||||
most restrictive along the chain, so without saying so the page would
|
||||
show a number as though it applied. */}}
|
||||
<div class="q-sub q-warn">{{.Shadowed}}</div>{{end}}
|
||||
</td>
|
||||
<td class="num">{{template "quota-value" dict "Own" .RPM "Up" .RPMUp "From" .RPMFrom}}</td>
|
||||
<td class="num">{{template "quota-value" dict "Own" .TPM "Up" .TPMUp "From" .TPMFrom}}</td>
|
||||
<td class="num">{{if .Budget}}{{.Budget}}{{if .Period}}<span class="q-per">/{{.Period}}</span>{{end}}
|
||||
{{else}}{{template "quota-value" dict "Own" "" "Up" .BudgetUp "From" .BudgetFrom}}{{end}}</td>
|
||||
<td class="num">{{template "quota-value" dict "Own" .Shown.RPM "Up" .RPMUp "From" .RPMFrom}}</td>
|
||||
<td class="num">{{template "quota-value" dict "Own" .Shown.TPM "Up" .TPMUp "From" .TPMFrom}}</td>
|
||||
<td class="num">{{if .Budget}}{{.Shown.Budget}}{{if .Period}}<span class="q-per">/{{.Shown.Period}}</span>{{end}}
|
||||
{{else}}{{template "quota-value" dict "Up" .BudgetUp "From" .BudgetFrom}}{{end}}</td>
|
||||
<td class="q-spent">
|
||||
<span class="q-spend">{{.Spend}}</span>
|
||||
{{if .HasPct}}<span class="q-meter{{if ge .Percent 90}} hot{{end}}" title="{{.Percent}}% of its budget"><i style="width:{{pct .Percent 100}}%"></i></span>
|
||||
<span class="q-pct">{{.Percent}}%</span>{{end}}
|
||||
{{if .HasPct}}<span class="q-meter{{if ge .Percent 90}} hot{{end}}" title="{{t "quotas.pct_of_budget" .Percent}}"><i style="width:{{pct .Percent 100}}%"></i></span>
|
||||
<span class="q-pct">{{t "quotas.pct" .Percent}}</span>{{end}}
|
||||
</td>
|
||||
<td class="q-act">
|
||||
{{if .Editing}}<a href="/admin/quotas">close</a>
|
||||
{{else}}<a href="/admin/quotas?edit={{.Scope}}:{{.ID}}">{{if .HasAny}}change{{else}}set{{end}}</a>{{end}}
|
||||
{{if .MemberEditing}}<a href="/admin/quotas">close</a>
|
||||
{{else}}<a href="/admin/quotas?edit={{.Scope}}-member:{{.ID}}" title="A limit each member here has separately">per member</a>{{end}}
|
||||
{{if .Editing}}<a href="/admin/quotas">{{t "quotas.close"}}</a>
|
||||
{{else}}<a href="/admin/quotas?edit={{.Scope}}:{{.ID}}">{{if .HasAny}}{{t "quotas.change"}}{{else}}{{t "quotas.set"}}{{end}}</a>{{end}}
|
||||
{{if .MemberEditing}}<a href="/admin/quotas">{{t "quotas.close"}}</a>
|
||||
{{else}}<a href="/admin/quotas?edit={{.Scope}}-member:{{.ID}}" title="{{t "quotas.per_member_title"}}">{{t "quotas.per_member"}}</a>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{if .MemberEditing}}
|
||||
<tr class="editing"><td colspan="6">
|
||||
{{template "quota-form" (dict "Scope" (printf "%s-member" .Scope) "ID" .ID "F" .MemberRaw "CSRF" $.CSRF
|
||||
"Title" (printf "Each member of %s, separately" .Crumb)
|
||||
"Note" "Every member gets this much here, counted apart from the others — a fair share rather than one pool.")}}
|
||||
"Title" (t "quotas.member_form.title" .Crumb)
|
||||
"Note" (t "quotas.member_form.note"))}}
|
||||
</td></tr>
|
||||
{{end}}
|
||||
|
||||
@@ -65,136 +62,121 @@ budgets bind.</p>
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="scope" value="{{.Scope}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<p class="q-form-title">Quota for <strong>{{.Crumb}}</strong>, shared by everything beneath it.
|
||||
A blank field means no limit of that kind, not a limit of zero.</p>
|
||||
<p class="q-form-title">{{th "quotas.form.title" .Crumb}}</p>
|
||||
<div class="field-row">
|
||||
<label>Requests / minute
|
||||
<input type="text" name="rpm" value="{{.RPM}}" placeholder="{{if .RPMUp}}{{.RPMUp}} from {{.RPMFrom}}{{else}}none{{end}}"></label>
|
||||
<label>Tokens / minute
|
||||
<input type="text" name="tpm" value="{{.TPM}}" placeholder="{{if .TPMUp}}{{.TPMUp}} from {{.TPMFrom}}{{else}}none{{end}}"></label>
|
||||
<label>Budget
|
||||
<input type="text" name="budget" value="{{.Budget}}" placeholder="{{if .BudgetUp}}{{.BudgetUp}} from {{.BudgetFrom}}{{else}}none{{end}}"></label>
|
||||
<label class="q-period">per
|
||||
<label>{{t "quotas.form.rpm"}}
|
||||
<input type="text" name="rpm" value="{{.RPM}}" placeholder="{{if .RPMUp}}{{t "quotas.form.up_from" .RPMUp .RPMFrom}}{{else}}{{t "common.none"}}{{end}}"></label>
|
||||
<label>{{t "quotas.form.tpm"}}
|
||||
<input type="text" name="tpm" value="{{.TPM}}" placeholder="{{if .TPMUp}}{{t "quotas.form.up_from" .TPMUp .TPMFrom}}{{else}}{{t "common.none"}}{{end}}"></label>
|
||||
<label>{{t "quotas.col.budget"}}
|
||||
<input type="text" name="budget" value="{{.Budget}}" placeholder="{{if .BudgetUp}}{{t "quotas.form.up_from" .BudgetUp .BudgetFrom}}{{else}}{{t "common.none"}}{{end}}"></label>
|
||||
<label class="q-period">{{t "quotas.form.per"}}
|
||||
<select name="period">
|
||||
<option value="month"{{if eq .Period "month"}} selected{{end}}>month</option>
|
||||
<option value="day"{{if eq .Period "day"}} selected{{end}}>day</option>
|
||||
<option value="month"{{if eq .Period "month"}} selected{{end}}>{{t "quotas.per.month"}}</option>
|
||||
<option value="day"{{if eq .Period "day"}} selected{{end}}>{{t "quotas.per.day"}}</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<label>Only these models <span class="opt">comma separated; blank allows all</span>
|
||||
<input type="text" name="models" value="{{.Models}}" placeholder="every model"></label>
|
||||
<label>{{t "quotas.form.models"}} <span class="opt">{{t "quotas.form.models_hint"}}</span>
|
||||
<input type="text" name="models" value="{{.Models}}" placeholder="{{t "quotas.form.every_model"}}"></label>
|
||||
<div class="q-form-actions">
|
||||
<button type="submit">Save</button>
|
||||
<a href="/admin/quotas">Cancel</a>
|
||||
<button type="submit">{{t "common.save"}}</button>
|
||||
<a href="/admin/quotas">{{t "common.cancel"}}</a>
|
||||
<button type="submit" name="clear" value="1" class="link danger"
|
||||
{{if not .HasAny}}disabled{{end}}>Remove every limit here</button>
|
||||
{{if not .HasAny}}disabled{{end}}>{{t "quotas.form.remove_all"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</td></tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
<p class="q-legend"><span class="q-up">60 ↑</span> is held by a limit set higher
|
||||
up — hover it to see where. A value set here is in normal type.</p>
|
||||
<p class="q-legend">{{th "quotas.legend"}}</p>
|
||||
{{else}}
|
||||
<p class="empty">Nothing to give a quota to yet. Make an organization on the
|
||||
<a href="/admin/users">Organizations & people</a> tab first.</p>
|
||||
<p class="empty">{{th "quotas.empty_tree"}}</p>
|
||||
{{end}}
|
||||
|
||||
<h2 id="people-limits">People</h2>
|
||||
<p class="note">A person's own limit covers everything they do, across all their
|
||||
keys, projects and the chat page. {{.Personal}}</p>
|
||||
<h2 id="people-limits">{{t "quotas.people.title"}}</h2>
|
||||
<p class="note">{{t "quotas.people.note"}} {{.Personal}}</p>
|
||||
{{if .People}}
|
||||
<table class="rows quota-tree people-limits">
|
||||
<tr><th>Person</th><th class="num">Requests/min</th><th class="num">Tokens/min</th>
|
||||
<th class="num">Budget</th><th>Spent</th><th></th></tr>
|
||||
<tr><th>{{t "quotas.col.person"}}</th><th class="num">{{t "quotas.col.rpm"}}</th><th class="num">{{t "quotas.col.tpm"}}</th>
|
||||
<th class="num">{{t "quotas.col.budget"}}</th><th>{{t "quotas.col.spent"}}</th><th></th></tr>
|
||||
{{range .People}}
|
||||
<tr{{if .Editing}} class="editing"{{end}}>
|
||||
<td class="q-where"><span class="q-name">{{.Email}}</span>
|
||||
{{if .Models}}<div class="q-sub">only <code>{{.Models}}</code></div>{{end}}</td>
|
||||
<td class="num">{{or .RPM "—"}}</td>
|
||||
<td class="num">{{or .TPM "—"}}</td>
|
||||
<td class="num">{{if .Budget}}{{.Budget}}{{if .Period}}<span class="q-per">/{{.Period}}</span>{{end}}{{else}}—{{end}}</td>
|
||||
{{if .Models}}<div class="q-sub">{{th "quotas.only_models" .Models}}</div>{{end}}</td>
|
||||
<td class="num">{{or .Shown.RPM "—"}}</td>
|
||||
<td class="num">{{or .Shown.TPM "—"}}</td>
|
||||
<td class="num">{{if .Budget}}{{.Shown.Budget}}{{if .Period}}<span class="q-per">/{{.Shown.Period}}</span>{{end}}{{else}}—{{end}}</td>
|
||||
<td class="q-spent"><span class="q-spend">{{.Spend}}</span>
|
||||
{{if .HasPct}}<span class="q-meter{{if ge .Percent 90}} hot{{end}}"><i style="width:{{pct .Percent 100}}%"></i></span>
|
||||
<span class="q-pct">{{.Percent}}%</span>{{end}}</td>
|
||||
<td class="q-act">{{if .Editing}}<a href="/admin/quotas">close</a>
|
||||
{{else}}<a href="/admin/quotas?edit=user:{{.ID}}">{{if .HasAny}}change{{else}}set{{end}}</a>{{end}}</td>
|
||||
<span class="q-pct">{{t "quotas.pct" .Percent}}</span>{{end}}</td>
|
||||
<td class="q-act">{{if .Editing}}<a href="/admin/quotas">{{t "quotas.close"}}</a>
|
||||
{{else}}<a href="/admin/quotas?edit=user:{{.ID}}">{{if .HasAny}}{{t "quotas.change"}}{{else}}{{t "quotas.set"}}{{end}}</a>{{end}}</td>
|
||||
</tr>
|
||||
{{if .Editing}}
|
||||
<tr class="editing"><td colspan="6">
|
||||
{{template "quota-form" (dict "Scope" "user" "ID" .ID "CSRF" $.CSRF
|
||||
"F" (dict "RPM" .RPM "TPM" .TPM "Budget" .Budget "Period" .Period "Models" .Models "HasAny" .HasAny)
|
||||
"Title" (printf "%s, everywhere" .Email)
|
||||
"Note" "Across every key this person holds, every project they use and the chat page.")}}
|
||||
"Title" (t "quotas.person_form.title" .Email)
|
||||
"Note" (t "quotas.person_form.note"))}}
|
||||
</td></tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
{{else}}<p class="empty">Nobody has been approved yet.</p>{{end}}
|
||||
{{else}}<p class="empty">{{t "quotas.people.empty"}}</p>{{end}}
|
||||
|
||||
{{if .HasCapacity}}
|
||||
<h2 id="capacity">Capacity</h2>
|
||||
<p class="note">What a part of the tree is guaranteed of the cluster, and the most
|
||||
it may hold: GPUs, CPU cores and memory, counted from the replicas of the models
|
||||
it owns and of those beneath it. What it holds within its guarantee is never taken
|
||||
for someone else's model; beyond it, it borrows free room and gives it back when
|
||||
asked. A model's owner is set on its card on the mirror page.</p>
|
||||
<h2 id="capacity">{{t "capacity.title"}}</h2>
|
||||
<p class="note">{{t "capacity.note"}}</p>
|
||||
{{if .Capacity}}
|
||||
<table class="rows quota-tree">
|
||||
<tr><th>Where</th><th class="num">GPUs</th><th class="num">Cores</th><th class="num">Memory GB</th><th>Set by</th><th></th></tr>
|
||||
<tr><th>{{t "quotas.col.where"}}</th><th class="num">GPUs</th><th class="num">{{t "capacity.col.cores"}}</th><th class="num">{{t "capacity.col.memory"}}</th><th>{{t "capacity.col.set_by"}}</th><th></th></tr>
|
||||
{{range .Capacity}}<tr>
|
||||
<td class="q-where"><span class="q-name">{{or .Name .Scope}}</span></td>
|
||||
<td class="num">{{or .GPUs "—"}}</td>
|
||||
<td class="num">{{or .Cores "—"}}</td>
|
||||
<td class="num">{{or .Memory "—"}}</td>
|
||||
<td class="note">{{.UpdatedBy}}</td>
|
||||
<td class="q-act"><a href="/admin/quotas?cap={{.Scope}}#capacity">change</a>
|
||||
<td class="q-act"><a href="/admin/quotas?cap={{.Scope}}#capacity">{{t "quotas.change"}}</a>
|
||||
<form method="post" action="/admin/quotas/capacity" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="scope" value="{{.Scope}}">
|
||||
<button type="submit" name="clear" value="1" class="link danger">remove</button>
|
||||
<button type="submit" name="clear" value="1" class="link danger">{{t "capacity.remove"}}</button>
|
||||
</form></td>
|
||||
</tr>{{end}}
|
||||
</table>
|
||||
<p class="q-legend">Each figure is held of guaranteed (at most the limit); — is not governed.</p>
|
||||
{{else}}<p class="empty">No capacity quotas: every model shares the cluster by its class alone.</p>{{end}}
|
||||
<p class="q-legend">{{t "capacity.legend"}}</p>
|
||||
{{else}}<p class="empty">{{t "capacity.empty"}}</p>{{end}}
|
||||
|
||||
{{if .Scopes}}
|
||||
<form method="post" action="/admin/quotas/capacity" class="q-form capacity-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<p class="q-form-title"><strong>{{if .CapForm.Scope}}Change{{else}}Set{{end}} a capacity quota.</strong>
|
||||
A blank field is not governed; 0 is none at all.</p>
|
||||
<label>Where
|
||||
<p class="q-form-title">{{if .CapForm.Scope}}{{th "capacity.form.title_change"}}{{else}}{{th "capacity.form.title_set"}}{{end}}</p>
|
||||
<label>{{t "quotas.col.where"}}
|
||||
<select name="scope" required>
|
||||
<option value="">choose…</option>
|
||||
<option value="">{{t "capacity.form.choose"}}</option>
|
||||
{{range .Scopes}}<option value="{{.Key}}"{{if eq .Key $.CapForm.Scope}} selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select></label>
|
||||
<div class="field-row">
|
||||
<label>Guaranteed GPUs <input type="text" name="gpus" value="{{.CapForm.GPUs}}" placeholder="—"></label>
|
||||
<label>Cores <input type="text" name="cores" value="{{.CapForm.Cores}}" placeholder="—"></label>
|
||||
<label>Memory GB <input type="text" name="memory" value="{{.CapForm.Memory}}" placeholder="—"></label>
|
||||
<label>{{t "capacity.form.g_gpus"}} <input type="text" name="gpus" value="{{.CapForm.GPUs}}" placeholder="—"></label>
|
||||
<label>{{t "capacity.col.cores"}} <input type="text" name="cores" value="{{.CapForm.Cores}}" placeholder="—"></label>
|
||||
<label>{{t "capacity.col.memory"}} <input type="text" name="memory" value="{{.CapForm.Memory}}" placeholder="—"></label>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>At most GPUs <input type="text" name="limit_gpus" value="{{.CapForm.LGPUs}}" placeholder="—"></label>
|
||||
<label>Cores <input type="text" name="limit_cores" value="{{.CapForm.LCores}}" placeholder="—"></label>
|
||||
<label>Memory GB <input type="text" name="limit_memory" value="{{.CapForm.LMem}}" placeholder="—"></label>
|
||||
<label>{{t "capacity.form.l_gpus"}} <input type="text" name="limit_gpus" value="{{.CapForm.LGPUs}}" placeholder="—"></label>
|
||||
<label>{{t "capacity.col.cores"}} <input type="text" name="limit_cores" value="{{.CapForm.LCores}}" placeholder="—"></label>
|
||||
<label>{{t "capacity.col.memory"}} <input type="text" name="limit_memory" value="{{.CapForm.LMem}}" placeholder="—"></label>
|
||||
</div>
|
||||
<div class="q-form-actions"><button type="submit">Save</button>
|
||||
{{if .CapForm.Scope}}<a href="/admin/quotas#capacity">Cancel</a>{{end}}</div>
|
||||
<div class="q-form-actions"><button type="submit">{{t "common.save"}}</button>
|
||||
{{if .CapForm.Scope}}<a href="/admin/quotas#capacity">{{t "common.cancel"}}</a>{{end}}</div>
|
||||
</form>
|
||||
{{else}}<p class="empty">Nothing to give a capacity quota to yet. Make an organization on the
|
||||
<a href="/admin/users">Organizations & people</a> tab first.</p>{{end}}
|
||||
{{else}}<p class="empty">{{th "capacity.empty_tree"}}</p>{{end}}
|
||||
{{end}}
|
||||
|
||||
<details class="explain">
|
||||
<summary>How limits combine</summary>
|
||||
<p>Along a branch the most restrictive value wins, so a team cannot raise what
|
||||
its organization set and a project cannot raise its team's. Only an
|
||||
organization's own limit is bounded by nothing. Model lists intersect: a
|
||||
project cannot allow a model its team withheld.</p>
|
||||
<p>Per-key limits are not set here: a key is bounded by its project, and
|
||||
putting every caller's throughput one click from an admin session is more
|
||||
than this page should do — <code>llmgw keys limits --scope key</code>.</p>
|
||||
<summary>{{t "quotas.explain.summary"}}</summary>
|
||||
<p>{{t "quotas.explain.chain"}}</p>
|
||||
<p>{{th "quotas.explain.keys"}}</p>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
@@ -209,26 +191,26 @@ asked. A model's owner is set on its card on the mirror page.</p>
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
{{/* From a panel on the people tab: the answer comes back to that panel. */}}
|
||||
{{with .Sel}}<input type="hidden" name="tab" value="people"><input type="hidden" name="sel" value="{{.}}">{{end}}
|
||||
<p class="q-form-title"><strong>{{.Title}}</strong>. {{.Note}} A blank field means no limit of that kind.</p>
|
||||
<p class="q-form-title">{{th "quotas.qform.title" .Title .Note}}</p>
|
||||
<div class="field-row">
|
||||
<label>Requests / minute <input type="text" name="rpm" value="{{.F.RPM}}" placeholder="none"></label>
|
||||
<label>Tokens / minute <input type="text" name="tpm" value="{{.F.TPM}}" placeholder="none"></label>
|
||||
<label>Budget <input type="text" name="budget" value="{{.F.Budget}}" placeholder="none"></label>
|
||||
<label class="q-period">per
|
||||
<label>{{t "quotas.form.rpm"}} <input type="text" name="rpm" value="{{.F.RPM}}" placeholder="{{t "common.none"}}"></label>
|
||||
<label>{{t "quotas.form.tpm"}} <input type="text" name="tpm" value="{{.F.TPM}}" placeholder="{{t "common.none"}}"></label>
|
||||
<label>{{t "quotas.col.budget"}} <input type="text" name="budget" value="{{.F.Budget}}" placeholder="{{t "common.none"}}"></label>
|
||||
<label class="q-period">{{t "quotas.form.per"}}
|
||||
<select name="period">
|
||||
<option value="month"{{if eq .F.Period "month"}} selected{{end}}>month</option>
|
||||
<option value="day"{{if eq .F.Period "day"}} selected{{end}}>day</option>
|
||||
<option value="month"{{if eq .F.Period "month"}} selected{{end}}>{{t "quotas.per.month"}}</option>
|
||||
<option value="day"{{if eq .F.Period "day"}} selected{{end}}>{{t "quotas.per.day"}}</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<label>Only these models <span class="opt">comma separated; blank allows all</span>
|
||||
<input type="text" name="models" value="{{.F.Models}}" placeholder="every model"></label>
|
||||
<label>{{t "quotas.form.models"}} <span class="opt">{{t "quotas.form.models_hint"}}</span>
|
||||
<input type="text" name="models" value="{{.F.Models}}" placeholder="{{t "quotas.form.every_model"}}"></label>
|
||||
<div class="q-form-actions">
|
||||
<button type="submit">Save</button>
|
||||
{{if not .Sel}}<a href="/admin/quotas">Cancel</a>{{end}}
|
||||
<button type="submit">{{t "common.save"}}</button>
|
||||
{{if not .Sel}}<a href="/admin/quotas">{{t "common.cancel"}}</a>{{end}}
|
||||
<button type="submit" name="clear" value="1" class="link danger"
|
||||
{{if not .F.HasAny}}disabled{{end}}>Remove these limits</button>
|
||||
{{if not .F.HasAny}}disabled{{end}}>{{t "quotas.qform.remove"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{define "quota-value"}}{{if .Own}}{{.Own}}{{else if .Up}}<span class="q-up" title="set on {{.From}}">{{.Up}} ↑</span>{{else}}<span class="q-none">—</span>{{end}}{{end}}
|
||||
{{define "quota-value"}}{{if .Own}}{{.Own}}{{else if .Up}}<span class="q-up" title="{{t "quotas.set_on" .From}}">{{.Up}} ↑</span>{{else}}<span class="q-none">—</span>{{end}}{{end}}
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user