diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b2eb3..4ddf293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,19 @@ in amber when short, in red when parked or when it does not fit. It is one line per model beside its mode, and nodes drop the part of the name every node shares (node05 for llmgw-node05, the full name on hover). +### One line per record, in every table + +The same rule across the admin pages. Headers do not wrap; a record is one +line; free text is cut to its column with the whole of it on hover rather than +given a row of its own; a row across the whole table is only a note or an open +form. On the cluster page that means a node is one line (its address and logs +beside its name, not under it), a model's description is a column rather than a +second row, node names drop the prefix they all share, and the GPUs, classes, +fabrics and fallbacks columns appear only when some row has something in them — +on a CPU-only cluster they were three columns of dashes pushing the table past +the page. On People, a person in many teams shows two and a count, the rest on +hover. + ## v0.10.4 — 2026-09-30 Published first as release candidates v0.10.4-rc.1 to rc.9. diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go index 27c3e26..0e5ba67 100644 --- a/internal/web/dashboard.go +++ b/internal/web/dashboard.go @@ -45,6 +45,13 @@ type DashboardData struct { HasPeople bool + // NodePrefix is what every node's name starts with, left off in tables + // so a row fits on a line; the full name is on hover. + NodePrefix string + // Columns shown only when some row has something in them: a CPU-only + // cluster's nodes table was three columns of dashes wide. + AnyGPUs, AnyClasses, AnyFabrics, AnyFallbacks bool + // Charts are pre-rendered rather than assembled in the template: building // SVG out of template fragments is how a chart ends up unescaped. RequestChart template.HTML @@ -177,6 +184,17 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat } } d.Unplaced = unplaced(src, d.Models) + ids := make([]string, 0, len(d.Nodes)) + for _, n := range d.Nodes { + ids = append(ids, n.ID) + d.AnyGPUs = d.AnyGPUs || n.GPUTotal > 0 + d.AnyClasses = d.AnyClasses || len(n.Classes) > 0 + d.AnyFabrics = d.AnyFabrics || len(n.Fabrics) > 0 + } + d.NodePrefix = sharedPrefix(ids) + for _, m := range d.Models { + d.AnyFallbacks = d.AnyFallbacks || len(m.Fallbacks) > 0 + } d.Summary = src.Summary() if fs, ok := src.(FabricSource); ok { r := fs.Fabrics() diff --git a/internal/web/rows_test.go b/internal/web/rows_test.go new file mode 100644 index 0000000..ad2ece9 --- /dev/null +++ b/internal/web/rows_test.go @@ -0,0 +1,54 @@ +package web + +import ( + "strings" + "testing" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" +) + +// cpuFleet is a CPU-only cluster whose nodes share a name prefix. +type cpuFleet struct{ fakeSource } + +func (cpuFleet) Nodes() []adminapi.NodeView { + return []adminapi.NodeView{ + {ID: "llmgw-node01", State: "ready", MgmtAddr: "10.0.0.141", MemoryGB: 8}, + {ID: "llmgw-node02", State: "ready", MgmtAddr: "10.0.0.142", MemoryGB: 8}, + } +} +func (cpuFleet) Summary() adminapi.Summary { return adminapi.Summary{} } +func (cpuFleet) Models() []adminapi.ModelView { + return []adminapi.ModelView{{Name: "qwen", Min: 1, Max: 1, Ready: 1, + Description: "A sentence long enough that it once had a row of its own under the model."}} +} +func (cpuFleet) Replicas(string) []adminapi.ReplicaView { + return []adminapi.ReplicaView{{ID: "qwen-1", Model: "qwen", State: "ready", + Shards: []adminapi.ShardView{{Node: "llmgw-node02"}}}} +} + +func TestTheClusterPageKeepsEachRecordOnOneLine(t *testing.T) { + h := Dashboard(cpuFleet{}, nil, "v1", time.Second, true, NavExtras{}, nil) + _, body := get(t, h, "http://127.0.0.1:9090/", nil) + + for _, gone := range []string{"
node01`) {
+ t.Error("node names should drop what every node shares, keeping the whole on hover")
+ }
+ if !strings.Contains(body, `node02`) {
+ t.Error("the replicas table should name nodes the same way")
+ }
+ if strings.Contains(body, `class="url-row"`) || !strings.Contains(body, `| {{.Label}} | {{.Value}} |
|---|
| Name | Key | Spends against | Created | Last used | State |
|---|
| Name | Key | Spends against | Created | Last used | State | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{.Name}} | {{.Prefix}} |
@@ -192,7 +192,7 @@ old one to stop moving, then revoke it.
{{end}}
{{if .Rows}}
-
| Limit | Value |
|---|
| Limit | Value |
|---|---|
| {{.Name}} | {{.Value}} |
No limits here.
{{end}} {{else}}Nowhere to spend yet.
{{end}} diff --git a/internal/web/templates/admin_mirror.html b/internal/web/templates/admin_mirror.html index 7574a7e..a97349c 100644 --- a/internal/web/templates/admin_mirror.html +++ b/internal/web/templates/admin_mirror.html @@ -34,7 +34,7 @@ reachable from this machine.| Model | Files | Size | Runs |
|---|
| Repository | Size | Architecture | Context | Downloads | Likes | Updated | |||
|---|---|---|---|---|---|---|---|---|---|
{{.ID}}
{{if .New}}new{{end}}{{if .Gated.Is}}gated{{end}}
- {{if or .BaseModel .License}}{{with .BaseModel}}from {{.}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}} {{end}} |
+ {{if or .BaseModel .License}}{{with .BaseModel}}from {{.}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}}{{end}}
{{.ParamsLabel}} | {{if .Architecture}}{{.Architecture}}{{if .Verdict.Checked}}{{if .Verdict.Servable}} ✓{{else}} ✗{{end}}{{end}}{{else}}—{{end}} | {{.ContextLabel}} | @@ -399,7 +399,7 @@ version and the old weights stay where they are. {{end}} {{if .RepoSets}} -
| What | Size | Published checksum | |
|---|---|---|---|
{{.Name}}
@@ -409,7 +409,7 @@ version and the old weights stay where they are.
|
{{if not .Verdict.Servable}}
cannot be served
- {{.Verdict.Reason}}
+ {{.Verdict.Reason}}
{{else}}
{{if .Verdict.Architecture}}{{.Verdict.Architecture}}{{if .Verdict.Checked}} ✓{{end}}{{end}}
|