diff --git a/CHANGELOG.md b/CHANGELOG.md index f1cf68c4..e618caa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,10 @@ rather than accepted and left short. A ceiling above the room is taken, and said. The same answer is at `GET /api/v1/models/{name}/headroom` on the admin API. +Each row also says how its model runs without being opened: replicas ready of +those it should have, how many are still starting, and the nodes they are on — +in amber when short, in red when parked or when it does not fit. + ## v0.10.4 — 2026-09-30 Published first as release candidates v0.10.4-rc.1 to rc.9. diff --git a/internal/web/admin_mirror.go b/internal/web/admin_mirror.go index a9f6d1c3..df7ace02 100644 --- a/internal/web/admin_mirror.go +++ b/internal/web/admin_mirror.go @@ -7,11 +7,13 @@ import ( "fmt" "html/template" "net/http" + "slices" "sort" "strconv" "strings" "time" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" "gitea.mynodes.xyz/ilker/llmgw/internal/gguf" "gitea.mynodes.xyz/ilker/llmgw/internal/hf" @@ -69,6 +71,16 @@ type modelUse struct { Replicas int // Min and Max are its scaling as the catalog holds it. Min, Max int + // Ready, Wanted and Nodes are how it runs now, for the row itself: + // replicas ready of those it should have, and the nodes they are on. + // Fleet is false where nothing could be asked, so the row says nothing + // rather than "0 of 0". + Fleet bool + Ready, Wanted int + Starting int + Nodes []string + Parked bool + Unplaceable bool // Declared models are read from server.yaml on every start, so a mode // changed here would revert; the page says where to change it instead. Declared bool @@ -433,13 +445,26 @@ func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request, } d.Rows = append(d.Rows, row) } + var views map[string]adminapi.ModelView + if p.fleet != nil && len(sources) > 0 { + views = map[string]adminapi.ModelView{} + for _, v := range p.fleet.Models() { + views[v.Name] = v + } + } for i := range d.Rows { for _, m := range sources[d.Rows[i].URL] { - d.Rows[i].UsedBy = append(d.Rows[i].UsedBy, modelUse{ + u := modelUse{ Name: m.Name, Mode: modeOf(m.Scaling), Replicas: max(m.Scaling.Max, 1), Min: m.Scaling.Min, Max: m.Scaling.Max, Declared: p.catalog.Declared(m.Name), - }) + } + if v, ok := views[m.Name]; ok { + u.Fleet, u.Ready, u.Wanted = true, v.Ready, v.Desired + u.Parked, u.Unplaceable = v.Parked, v.Unplaceable + u.Nodes, u.Starting = runningOn(p.fleet.Replicas(m.Name)) + } + d.Rows[i].UsedBy = append(d.Rows[i].UsedBy, u) } } @@ -761,3 +786,20 @@ func writeJSONStatus(w http.ResponseWriter, code int, v any) { w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } + +// runningOn is the nodes a model's replicas are on, each once and in order, +// and how many of its replicas are not ready yet. +func runningOn(reps []adminapi.ReplicaView) (nodes []string, starting int) { + for _, r := range reps { + if r.State != "ready" { + starting++ + } + for _, sh := range r.Shards { + if !slices.Contains(nodes, sh.Node) { + nodes = append(nodes, sh.Node) + } + } + } + slices.Sort(nodes) + return nodes, starting +} diff --git a/internal/web/mirror_detail_test.go b/internal/web/mirror_detail_test.go index d9c434f2..879c5d65 100644 --- a/internal/web/mirror_detail_test.go +++ b/internal/web/mirror_detail_test.go @@ -143,3 +143,24 @@ func TestAModelSetInConfigurationIsShownButNotChanged(t *testing.T) { t.Error("room that reached the limit asked should read as at least") } } + +func TestAHeldRowSaysHowItsModelRunsWithoutOpening(t *testing.T) { + body := detailPage(t, &roomyFleet{more: 1}, coderCatalog())("/admin/mirror") + if strings.Contains(body, "held-card") { + t.Fatal("the card opened by itself") + } + if !strings.Contains(body, `1/1 ready · n2`) { + t.Errorf("the row does not say where its replicas run: %s", body) + } +} + +func TestRunningOnNamesEachNodeOnceAndCountsWhatIsStarting(t *testing.T) { + nodes, starting := runningOn([]adminapi.ReplicaView{ + {State: "ready", Shards: []adminapi.ShardView{{Node: "n2"}}}, + {State: "starting", Shards: []adminapi.ShardView{{Node: "n1"}}}, + {State: "ready", Shards: []adminapi.ShardView{{Node: "n2"}}}, + }) + if strings.Join(nodes, ",") != "n1,n2" || starting != 1 { + t.Errorf("nodes %v, starting %d", nodes, starting) + } +} diff --git a/internal/web/static/base.css b/internal/web/static/base.css index cfb0404c..34d77b77 100644 --- a/internal/web/static/base.css +++ b/internal/web/static/base.css @@ -831,3 +831,7 @@ a.open-held { text-decoration: none; color: inherit; } a.open-held:hover strong { text-decoration: underline; } a.open-held[aria-expanded=true]::before { content: "▾ "; color: var(--muted); } a.open-held[aria-expanded=false]::before { content: "▸ "; color: var(--muted); } +.use .runs-now { display: block; font-size: .78rem; color: var(--muted); margin: .1rem 0 .25rem; } +.use .runs-now.short { color: var(--warn); } +.use .runs-now.bad { color: var(--bad); } +.use .runs-now .node { white-space: nowrap; } diff --git a/internal/web/templates/admin_mirror.html b/internal/web/templates/admin_mirror.html index 364534bb..a6ca040f 100644 --- a/internal/web/templates/admin_mirror.html +++ b/internal/web/templates/admin_mirror.html @@ -67,6 +67,12 @@ reachable from this machine.
{{/* 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}}as {{.Name}}{{end}} + {{if .Fleet}} + {{- if .Parked}}parked + {{- else if and (eq .Wanted 0) (eq .Ready 0)}}no replicas + {{- else}}{{.Ready}}/{{.Wanted}} ready{{if .Starting}}, {{.Starting}} starting{{end}}{{end}} + {{- if .Unplaceable}} · does not fit{{end}} + {{- with .Nodes}} · {{range $i, $n := .}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}{{end}} {{if .Declared}}{{if eq .Mode "always"}}always on{{else if eq .Mode "ondemand"}}on demand{{else}}stopped{{end}}