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) <noreply@anthropic.com>
This commit is contained in:
1 parent
d7f1c1d545
commit
a32ea0e5ab
5 files changed
+79
-2
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, `<span class="runs-now">1/1 ready · <span class="node">n2</span></span>`) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -67,6 +67,12 @@ reachable from this machine.</p>
|
||||
{{/* 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 .Fleet}}<span class="runs-now{{if or .Parked .Unplaceable}} bad{{else if lt .Ready .Wanted}} short{{end}}">
|
||||
{{- 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}}<span class="node">{{$n}}</span>{{end}}{{end}}</span>{{end}}
|
||||
{{if .Declared}}<span class="opt">{{if eq .Mode "always"}}always on{{else if eq .Mode "ondemand"}}on demand{{else}}stopped{{end}}<br>set in server.yaml</span>
|
||||
{{else}}
|
||||
{{/* Submitted on change: the choice is the action, and a second click
|
||||
|
||||
Reference in new issue
Block a user