From a32ea0e5abdadcaf1cb074ac80dba13e752f5d58 Mon Sep 17 00:00:00 2001
From: Ilker Manap
Date: Wed, 30 Sep 2026 10:38:49 +0200
Subject: [PATCH] Say on each held row how its model runs and where
Ready of wanted, how many are starting, and the nodes, from the fleet data
the card already reads, so the list answers it without a click.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
CHANGELOG.md | 4 +++
internal/web/admin_mirror.go | 46 ++++++++++++++++++++++--
internal/web/mirror_detail_test.go | 21 +++++++++++
internal/web/static/base.css | 4 +++
internal/web/templates/admin_mirror.html | 6 ++++
5 files changed, 79 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1cf68c..e618caa 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 a9f6d1c..df7ace0 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 d9c434f..879c5d6 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 cfb0404..34d77b7 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 364534b..a6ca040 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}}
set in server.yaml
{{else}}
{{/* Submitted on change: the choice is the action, and a second click