Turkish pages are Turkish to the end
CI / test (push) Successful in 5m35s

The errors other packages give a page (store, catalog, mirror) are
shown in its language; a percentage is written as the language writes
it (%93 in Turkish); the footer's time in its date form; and a stale
form is refused with a sentence rather than "bad request".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-21 09:22:42 +02:00
1 parent 3a4488b41e
commit f8d18efaef
36 files changed
+203 -46

No files matched your search

+2 -1
View File
@@ -28,7 +28,8 @@ What is left, most needed first. The detail is in the item each names.
3. **K**: fixes from the fabric test the operator runs with docs/large-models.md.
**Soon: the customer's experience, and HA.**
4. **C24**: English errors on Turkish pages. (S)
4. ~~**C24**~~: done 2026-10-21. The store's, catalog's and mirror's errors, percent signs, footer times and stale
forms are in the page's language. Left: the /docs documents, and a language kept per person (cookie only today).
5. **J4**: the model mirror on more than one machine, the last single point of an HA install. (M)
6. **F5**: large imports limited by the embedding engine's slots. (M)
+7
View File
@@ -74,6 +74,13 @@ APIs change between minors without a deprecation period.
### Fixed
- **Turkish pages are Turkish to the end.**
- **Errors:** those from the store, catalog and mirror that a page shows ("not found", "not empty", "already in the
catalog", the settings changed meanwhile, and others) are now in the page's language.
- **Percent:** a percentage is written the language's way: `%93` in Turkish, `93%` in English.
- **Footer:** the time at the foot of a page is in the language's date form.
- **Stale forms:** they are refused with a sentence in that language rather than "bad request".
- **`llmgw keys limits` no longer clears the limits it was not given.** Setting one limit at a scope wrote the whole
row, so `--tools 'jira.*'` alone took away that scope's rate, budget and model list. Only what is given changes now.
`--replace` writes every limit, as the console's limits form does.
+4 -4
View File
@@ -344,7 +344,7 @@ func (p *AccountPages) logout(w http.ResponseWriter, r *http.Request) {
// page can sign a visitor out of this one.
sess, _, ok := p.session(r)
if ok && !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
if c, err := r.Cookie(SessionCookie); err == nil {
@@ -437,7 +437,7 @@ func (p *AccountPages) createKey(w http.ResponseWriter, r *http.Request) {
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
@@ -487,7 +487,7 @@ func (p *AccountPages) revokeKey(w http.ResponseWriter, r *http.Request) {
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
@@ -721,7 +721,7 @@ func (p *AccountPages) changePassword(w http.ResponseWriter, r *http.Request) {
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
+1 -1
View File
@@ -401,7 +401,7 @@ func (p *AccountPages) mirrorAction(m MirrorAdmin,
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
sess, _, _ := p.session(r)
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
msg, err := do(r.Context(), admin, r)
+4 -3
View File
@@ -21,15 +21,16 @@ func (p *AccountPages) RegisterNodeEngines(mux *http.ServeMux, a NodeEngineAdmin
mux.HandleFunc("POST /admin/nodes/{id}/engines", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
sess, _, _ := p.session(r)
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
node, engine, build := r.PathValue("id"), r.FormValue("engine"), r.FormValue("build")
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())
// The control plane's reason, in the page's language where it
// is one the pages know, else as it gave it.
q.Set("problem", node+": "+langOf(r).Err(err))
} else {
p.log.Info("an engine build was chosen", "admin", admin.Email, "node", node, "engine", engine, "build", build)
// Said in the language of the person who chose, since the
+5 -4
View File
@@ -108,7 +108,7 @@ func (p *AccountPages) RegisterPresets(mux *http.ServeMux, a PresetAdmin) {
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
sess, _, _ := p.session(r)
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
h(w, r, admin)
@@ -120,7 +120,7 @@ func (p *AccountPages) RegisterPresets(mux *http.ServeMux, a PresetAdmin) {
q := url.Values{}
if err := a.SetAutoPresets(r.Context(), on, admin.Email); err != nil {
p.log.Warn("presets.auto was not changed", "admin", admin.Email, "err", err)
q.Set("problem", err.Error())
q.Set("problem", langOf(r).Err(err))
} else {
p.log.Info("presets.auto changed", "admin", admin.Email, "on", on)
key := "dash.presets.notice.auto_off"
@@ -136,8 +136,9 @@ func (p *AccountPages) RegisterPresets(mux *http.ServeMux, a PresetAdmin) {
q := url.Values{}
if err := a.AddPreset(r.Context(), name, admin.Email); err != nil {
p.log.Warn("a preset was not added", "admin", admin.Email, "preset", name, "err", err)
// The control plane's reason, as it gave it: in English.
q.Set("problem", name+": "+err.Error())
// The control plane's reason, in the page's language where it
// is one the pages know, else as it gave it.
q.Set("problem", name+": "+langOf(r).Err(err))
} else {
p.log.Info("a preset was added", "admin", admin.Email, "preset", name)
q.Set("notice", langOf(r).T("dash.presets.notice.added", name))
+2 -2
View File
@@ -210,7 +210,7 @@ func (p *AccountPages) RegisterAdminQuotas(mux *http.ServeMux, s QuotaAdmin, pri
func(w http.ResponseWriter, r *http.Request, admin store.User) {
sess, _, _ := p.session(r)
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
msg, err := p.applyQuota(r, s, admin)
@@ -228,7 +228,7 @@ func (p *AccountPages) RegisterAdminQuotas(mux *http.ServeMux, s QuotaAdmin, pri
func(w http.ResponseWriter, r *http.Request, admin store.User) {
sess, _, _ := p.session(r)
if p.capacity == nil || !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
msg, err := p.applyCapacity(r, admin)
+1 -1
View File
@@ -89,7 +89,7 @@ func (p *AccountPages) RegisterAdminSettings(mux *http.ServeMux, s SettingsAdmin
if r.Method == http.MethodPost {
sess, _, _ := p.session(r)
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
}
+2 -2
View File
@@ -476,7 +476,7 @@ func (p *AccountPages) tenancyAction(s AccountAdmin,
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
sess, _, _ := p.session(r)
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
l := langOf(r)
@@ -524,7 +524,7 @@ func (p *AccountPages) adminAction(s AccountAdmin,
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
sess, _, _ := p.session(r)
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
id := r.PathValue("id")
+1 -1
View File
@@ -125,7 +125,7 @@ func (p *AccountPages) Guard(
token = r.FormValue("csrf")
}
if !p.accounts.CheckCSRF(sess, token) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
}
+1 -1
View File
@@ -164,7 +164,7 @@ func (p *AccountPages) connect(w http.ResponseWriter, r *http.Request) {
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
l := langOf(r)
+44
View File
@@ -0,0 +1,44 @@
package web
import (
"net/http"
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// The errors of other packages a page can show, in the page's language: an
// administrator's page in Turkish said "not found" and "not empty" in
// English, because they came from the store as written there.
func init() {
for err, key := range map[error]string{
store.ErrNotFound: "err.not_found",
store.ErrNotEmpty: "err.not_empty",
store.ErrDuplicateName: "err.duplicate_name",
store.ErrEmailTaken: "err.email_taken",
store.ErrKeyTTL: "err.key_ttl",
store.ErrNotMember: "err.not_member",
store.ErrOwnerInactive: "err.owner_inactive",
store.ErrSettingsChanged: "err.settings_changed",
store.ErrNoSession: "err.no_session",
store.ErrKnowledgeOff: "err.knowledge_off",
store.ErrMemoryNotEnabled: "err.memory_off",
store.ErrMemoryFull: "err.memory_full",
store.ErrThreadsNotEnabled: "err.threads_off",
account.ErrNoReset: "err.no_reset",
catalog.ErrExists: "err.in_catalog",
mirror.ErrAlreadyFetching: "err.mirror_fetching",
mirror.ErrExists: "err.mirror_exists",
mirror.ErrIncomplete: "err.mirror_incomplete",
} {
registerErrorKey(err, key)
}
}
// badForm answers a form whose token did not match: one left open too long,
// or sent from another site. In the page's language, plainly.
func badForm(w http.ResponseWriter, r *http.Request) {
http.Error(w, langOf(r).T("err.bad_form"), http.StatusBadRequest)
}
+41
View File
@@ -0,0 +1,41 @@
package web
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// A Turkish page says the store's errors, percentages and moments the
// Turkish way, and a stale form plainly.
func TestATurkishPageSpeaksTurkishToTheEnd(t *testing.T) {
tr, en := langByCode("tr"), langByCode("en")
wrapped := fmt.Errorf("org Acme: %w", store.ErrNotEmpty)
if got := tr.Err(wrapped); !strings.Contains(got, "boş değil") {
t.Errorf("a wrapped store error in Turkish: %q", got)
}
if got := en.Err(store.ErrNotFound); !strings.Contains(got, "not there") {
t.Errorf("in English: %q", got)
}
if got := tr.Pct(93, 0); got != "%93" {
t.Errorf("Turkish percent %q", got)
}
if got := en.Pct(93.5, 1); got != "93.5%" {
t.Errorf("English percent %q", got)
}
stamp := tr.funcs()["stamp"].(func(string) string)
if got := stamp("2026-10-21T08:05:00Z"); got != "21.10.2026 08:05 UTC" {
t.Errorf("a Turkish moment %q", got)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/x", nil)
req.Header.Set("Accept-Language", "tr")
badForm(rec, req)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "form süresi") {
t.Errorf("a stale form in Turkish: %d %q", rec.Code, rec.Body.String())
}
}
+18
View File
@@ -55,6 +55,9 @@ type Lang struct {
decimal, group string
// date and dateTime are Go layouts.
date, dateTime string
// percent writes a percentage around its number: "%s%%" in English,
// "%%%s" in Turkish, which puts the sign first.
percent string
tmpl *template.Template
}
@@ -149,6 +152,7 @@ func loadLang(fsys fs.FS, code string) (*Lang, error) {
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")
l.percent = cmpOr(l.msgs["_meta.percent"], "%s%%")
return l, nil
}
@@ -375,6 +379,9 @@ func registerKeys(keys ...string) {
// Numbers.
// Pct writes a percentage the way this language does: 93% or %93.
func (l *Lang) Pct(v float64, places int) string { return fmt.Sprintf(l.percent, l.Num(v, places)) }
// 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) {
@@ -624,6 +631,17 @@ func (l *Lang) funcs() template.FuncMap {
// 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) },
// stamp is a moment kept as RFC 3339, the way this language writes
// dates and times.
"stamp": func(s string) string {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return s
}
return t.UTC().Format(l.dateTime) + " UTC"
},
// pc is a percentage, its sign where the language puts it.
"pc": func(v any, places int) string { return l.Pct(toFloat(v), places) },
"count": func(v any) string {
if n, ok := toInt(v); ok {
return l.Int(n)
+2 -2
View File
@@ -37,7 +37,7 @@ func TestTheDashboardSpeaksTurkishWhenAsked(t *testing.T) {
// 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",
"<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.
@@ -67,7 +67,7 @@ func TestTheNodePageWritesTurkishNumbers(t *testing.T) {
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",
"64 çekirdek", "bellek %64", "71,2 <span class=\"note\">/ 80 GB", "1.980 MHz",
"3 düzeltilemeyen ECC hatası", "<h2>GPU&#39;lar</h2>",
"Meşgul GPU, karta göre", "GPU sıcaklığı, °C", "Son bir saat",
// The chart's axis and peak in Turkish figures.
+2 -2
View File
@@ -101,11 +101,11 @@ func (p *AccountPages) setLicences(w http.ResponseWriter, r *http.Request) {
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
badForm(w, r)
return
}
var ex []string
+1
View File
@@ -4,6 +4,7 @@
"_meta.group": "",
"_meta.date": "2006-01-02",
"_meta.datetime": "2006-01-02 15:04",
"_meta.percent": "%s%%",
"lang.switch": "Language",
+21
View File
@@ -0,0 +1,21 @@
{
"err.not_found": "it is not there: it may have been removed meanwhile",
"err.not_empty": "it is not empty: remove what is in it first",
"err.duplicate_name": "something of that name is already here",
"err.email_taken": "that address already has an account",
"err.key_ttl": "a key may not live that long here",
"err.not_member": "not a member of that project",
"err.owner_inactive": "the key's owner is not an active account",
"err.settings_changed": "the cluster's settings were changed meanwhile: open them again and make the change there",
"err.no_session": "you are not signed in",
"err.knowledge_off": "knowledge bases are off on this gateway",
"err.memory_off": "this project does not keep memory notes",
"err.memory_full": "this agent has reached its limit of notes",
"err.threads_off": "this project does not keep conversations",
"err.no_reset": "there is no reset link for that address",
"err.in_catalog": "a model of that name is already in the catalog",
"err.mirror_fetching": "a download of that version is already running",
"err.mirror_exists": "that version is already in the mirror",
"err.mirror_incomplete": "that version is not complete in the mirror",
"err.bad_form": "the form had expired, or did not come from this site: go back, reload the page and try again"
}
+1
View File
@@ -4,6 +4,7 @@
"_meta.group": ".",
"_meta.date": "02.01.2006",
"_meta.datetime": "02.01.2006 15:04",
"_meta.percent": "%%%s",
"lang.switch": "Dil",
+1 -1
View File
@@ -30,7 +30,7 @@
"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.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.",
+21
View File
@@ -0,0 +1,21 @@
{
"err.not_found": "bulunamadı: bu arada kaldırılmış olabilir",
"err.not_empty": "boş değil: önce içindekileri kaldırın",
"err.duplicate_name": "bu adla bir şey zaten var",
"err.email_taken": "bu adresin zaten bir hesabı var",
"err.key_ttl": "burada bir anahtar bu kadar uzun yaşayamaz",
"err.not_member": "o projenin üyesi değil",
"err.owner_inactive": "anahtarın sahibi etkin bir hesap değil",
"err.settings_changed": "cluster ayarları bu arada değişti: ayarları yeniden açıp değişikliği orada yapın",
"err.no_session": "giriş yapmadınız",
"err.knowledge_off": "bu gateway'de bilgi tabanları kapalı",
"err.memory_off": "bu proje not tutmuyor",
"err.memory_full": "bu ajan not sınırına ulaştı",
"err.threads_off": "bu proje konuşmaları tutmuyor",
"err.no_reset": "bu adres için bir sıfırlama bağlantısı yok",
"err.in_catalog": "bu adla bir model katalogda zaten var",
"err.mirror_fetching": "bu sürümün indirmesi zaten sürüyor",
"err.mirror_exists": "bu sürüm mirror'da zaten var",
"err.mirror_incomplete": "bu sürüm mirror'da eksik",
"err.bad_form": "form süresi dolmuş ya da bu siteden gelmemiş: geri dönün, sayfayı yenileyip yeniden deneyin"
}
+1 -1
View File
@@ -18,7 +18,7 @@
"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.gpu.memory_util": "bellek %%%s",
"node.of_gb": "/ %s GB",
"node.of": "/ %s",
"node.gpu.nothing": "hiçbir şey",
+1 -1
View File
@@ -239,7 +239,7 @@ document.addEventListener("click", (e) => {
</form>
<p class="note">{{t "profile.password_note"}}</p>
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div></body></html>{{end}}
{{define "forgot"}}<!doctype html>
+1 -1
View File
@@ -437,7 +437,7 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<p class="note">{{t "mirror.fetch.note"}}</p>
{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div></body></html>{{end}}
{{/* One repository: its card and its files. The whole page when opened by
+1 -1
View File
@@ -34,7 +34,7 @@
{{end}}
</div>
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div>
<script>
// Search the account list. Outside #people so what was typed survives a swap,
+1 -1
View File
@@ -70,7 +70,7 @@
<p class="note">{{th "chat.empty_why"}}</p>
{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div>
<script>
+1 -1
View File
@@ -40,5 +40,5 @@ client = OpenAI(base_url="{{.BaseURL}}/v1", api_key=os.environ["LLMGW_API_KEY"])
<p class="note">{{t "claim.expires" .ExpiresAt}}</p>
{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div></body></html>{{end}}
+4 -4
View File
@@ -66,7 +66,7 @@
room is paying. */}}
<table class="rows"><tr><th>{{t "dash.col.model"}}</th><th class="num">GPUs</th><th class="num">{{t "dash.eff.tps"}}</th><th class="num">{{t "dash.eff.per_gpu"}}</th><th class="num">{{t "dash.eff.busy"}}</th><th class="num">{{t "dash.eff.draw"}}</th><th class="num">{{t "dash.eff.per_wh"}}</th></tr>
{{range .}}<tr><td>{{.Model}}</td><td class="num">{{num .GPUs 0}}</td><td class="num">{{num .TokensPerSec 1}}</td>
<td class="num">{{num .PerGPU 1}}</td><td class="num">{{num .Util 0}}%</td>
<td class="num">{{num .PerGPU 1}}</td><td class="num">{{pc .Util 0}}</td>
<td class="num">{{if .Watts}}{{num .Watts 0}} W{{else}}—{{end}}</td><td class="num">{{if .PerWh}}{{num .PerWh 0}}{{else}}—{{end}}</td></tr>
{{end}}</table>
{{end}}
@@ -77,7 +77,7 @@
be is the operator's call. */}}
<table class="rows"><tr><th>{{t "dash.col.model"}}</th><th>{{t "dash.col.replica"}}</th><th>{{t "dash.col.node"}}</th><th class="num">GPUs</th><th class="num">{{t "dash.idle.busiest"}}</th><th>{{t "dash.idle.last_request"}}</th></tr>
{{range .}}<tr><td>{{.Model}}</td><td class="clip"><code>{{.Replica}}</code></td><td><code>{{.Node}}</code></td>
<td class="num">{{len .GPUs}}</td><td class="num">{{num .MaxUtil 0}}%</td><td>{{if .Idle}}{{t "dash.idle.ago" .Idle}}{{else}}<span class="note">{{t "dash.idle.unknown"}}</span>{{end}}</td></tr>
<td class="num">{{len .GPUs}}</td><td class="num">{{pc .MaxUtil 0}}</td><td>{{if .Idle}}{{t "dash.idle.ago" .Idle}}{{else}}<span class="note">{{t "dash.idle.unknown"}}</span>{{end}}</td></tr>
{{end}}</table>
{{end}}
@@ -93,7 +93,7 @@
<td class="state state-{{.State}}">{{t (print "dash.state." .State)}}</td>
{{/* A dash, not an empty bar, when the agent has not measured it lately:
unknown is not idle. */}}
<td class="load">{{with .Load}}{{template "minibar" pctbar .CPUPercent}}<span class="load-v">{{num .CPUPercent 0}}%{{if .CPUs}} <span class="note">{{t "dash.nodes.cpus_of" .CPUs}}</span>{{end}}</span>{{else}}—{{end}}</td>
<td class="load">{{with .Load}}{{template "minibar" pctbar .CPUPercent}}<span class="load-v">{{pc .CPUPercent 0}}{{if .CPUs}} <span class="note">{{t "dash.nodes.cpus_of" .CPUs}}</span>{{end}}</span>{{else}}—{{end}}</td>
<td class="load">{{with .Load}}{{template "minibar" pctbar .MemPercent}}<span class="load-v">{{bytes .MemUsed}} <span class="note">{{t "dash.nodes.mem_of" (bytes .MemTotal)}}</span></span>{{else}}{{if .MemoryGB}}{{.MemoryGB}}G{{else}}—{{end}}{{end}}</td>
{{if $.AnyGPUs}}<td class="num">{{if .GPUTotal}}{{.GPUFree}}/{{.GPUTotal}}{{else}}—{{end}}</td>{{end}}
{{/* Free out of total, because the first is meaningless without the
@@ -360,7 +360,7 @@ document.querySelectorAll("form.build-choice select").forEach(function (s) {
<footer>
<span>llmgw {{.Version}} · {{t "common.refreshes_every" .RefreshSec}}</span>
<span>{{.Now}}</span>
<span>{{stamp .Now}}</span>
{{template "langs"}}</footer>
</div></body></html>{{end}}
+1 -1
View File
@@ -70,5 +70,5 @@
<script src="{{asset "fabric.js"}}" defer></script>
{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div></body></html>{{end}}
+1 -1
View File
@@ -61,7 +61,7 @@
<p class="note">{{th "images.empty_why"}}</p>
{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div>
<script>
+1 -1
View File
@@ -171,7 +171,7 @@ print(reply.choices[0].message.content)</code></pre>
<li><code>GET {{.BaseURL}}/me</code><span>{{t "landing.me_endpoint"}}</span></li>
</ul>
<footer><span><a href="/about">llmgw {{.Version}}</a></span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span><a href="/about">llmgw {{.Version}}</a></span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div>
<script>
+1 -1
View File
@@ -48,7 +48,7 @@
<p class="note">{{th "meshes.empty_why"}}</p>
{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div>
<script>
+3 -3
View File
@@ -21,7 +21,7 @@
{{with .Node}}
<div class="tiles">
{{with .Load}}
<div class="tile"><div class="v">{{num .CPUPercent 0}}%</div><div class="k">CPU{{if .CPUs}} · {{t "node.cores" .CPUs}}{{end}}</div>{{template "minibar" pctbar .CPUPercent}}</div>
<div class="tile"><div class="v">{{pc .CPUPercent 0}}</div><div class="k">CPU{{if .CPUs}} · {{t "node.cores" .CPUs}}{{end}}</div>{{template "minibar" pctbar .CPUPercent}}</div>
<div class="tile"><div class="v">{{bytes .MemUsed}}</div><div class="k">{{t "node.ram_in_use_of" (bytes .MemTotal)}}</div>{{template "minibar" pctbar .MemPercent}}</div>
<div class="tile"><div class="v">{{num .Load1 2}}</div><div class="k">{{t "node.load" (num .Load5 2) (num .Load15 2)}}</div></div>
{{/* Only what this machine has sensors for: a VM has none, and a tile
@@ -45,7 +45,7 @@
<td class="num">{{.Index}}</td>
{{/* From the last sample; a dash where the driver or an older agent did
not say, rather than a zero nobody measured. */}}
<td class="load">{{if and .MemoryTotalMB (ge .UtilPercent 0.0)}}{{template "minibar" pctbar .UtilPercent}}<span class="load-v">{{num .UtilPercent 0}}%{{if ge .MemUtilPercent 0.0}} <span class="note">{{t "node.gpu.memory_util" (num .MemUtilPercent 0)}}</span>{{end}}</span>{{else}}—{{end}}</td>
<td class="load">{{if and .MemoryTotalMB (ge .UtilPercent 0.0)}}{{template "minibar" pctbar .UtilPercent}}<span class="load-v">{{pc .UtilPercent 0}}{{if ge .MemUtilPercent 0.0}} <span class="note">{{t "node.gpu.memory_util" (num .MemUtilPercent 0)}}</span>{{end}}</span>{{else}}—{{end}}</td>
<td class="load">{{if .MemoryTotalMB}}{{template "minibar" pctbar (percent .MemoryUsedMB .MemoryTotalMB)}}<span class="load-v">{{num (gib .MemoryUsedMB) 1}} <span class="note">{{t "node.of_gb" (num (gib .MemoryTotalMB) 0)}}</span></span>{{else}}—{{end}}</td>
<td class="num">{{if .TempC}}{{num .TempC 0}} °C{{else}}—{{end}}</td>
<td class="num">{{if .MemoryTempC}}{{num .MemoryTempC 0}} °C{{else}}—{{end}}</td>
@@ -151,5 +151,5 @@
<p class="note">{{th "node.no_store"}}</p>
{{end}}
<footer><span>llmgw {{.Version}} · {{t "common.refreshes_every" .RefreshSec}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span>llmgw {{.Version}} · {{t "common.refreshes_every" .RefreshSec}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div></body></html>{{end}}
+1 -1
View File
@@ -28,5 +28,5 @@
<script>window.scrollTo(0, document.body.scrollHeight);</script>
{{else if not .Error}}<p class="empty">{{t "logs.empty"}}</p>{{end}}
<footer class="note">llmgw {{.Version}} · {{.Now}}{{template "langs"}}</footer>
<footer class="note">llmgw {{.Version}} · {{stamp .Now}}{{template "langs"}}</footer>
</div></body></html>{{end}}
+1 -1
View File
@@ -49,5 +49,5 @@
<p class="note">{{t "nvlink.note"}}</p>
<footer><span>llmgw {{.Version}} · {{t "common.refreshes_every" 30}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span>llmgw {{.Version}} · {{t "common.refreshes_every" 30}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div></body></html>{{end}}
+2 -2
View File
@@ -87,7 +87,7 @@
</table>
{{else}}<p class="empty">{{t "org.members.empty"}}</p>{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div></body></html>{{end}}
@@ -103,5 +103,5 @@
</header>
<p>{{t "org.none.about"}}</p>
<p>{{th "org.none.how"}}</p>
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div></body></html>{{end}}