From 235bf33dcd0dd0585e2602fd80a16352eaa93cda Mon Sep 17 00:00:00 2001
From: Ilker Manap
Date: Wed, 30 Sep 2026 11:02:18 +0200
Subject: [PATCH] Keep every admin table to one line per record
Headers never wrap, free text is clipped with the whole on hover, full-width
rows are only notes and forms. The cluster page hides columns empty on every
row and shortens node names; People shows two memberships and a count.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
CHANGELOG.md | 13 ++++++
internal/web/dashboard.go | 18 ++++++++
internal/web/rows_test.go | 54 ++++++++++++++++++++++++
internal/web/static/base.css | 17 ++++++++
internal/web/templates/account.html | 6 +--
internal/web/templates/admin_mirror.html | 12 +++---
internal/web/templates/admin_quotas.html | 4 +-
internal/web/templates/admin_usage.html | 2 +-
internal/web/templates/admin_users.html | 18 +++++---
internal/web/templates/dashboard.html | 24 +++++------
internal/web/templates/node.html | 8 ++--
internal/web/templates/org.html | 6 +--
internal/web/web.go | 2 +
13 files changed, 146 insertions(+), 38 deletions(-)
create mode 100644 internal/web/rows_test.go
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{"
Classes
", "
Fabrics
", `
GPUs
`, "
Fallbacks
"} {
+ if strings.Contains(body, gone) {
+ t.Errorf("%s is shown with nothing in it on any row", gone)
+ }
+ }
+ if !strings.Contains(body, `title="llmgw-node01">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, `
GPUs`) || !strings.Contains(gpu, "
Fabrics
") {
+ t.Error("columns with something in them were hidden")
+ }
+}
diff --git a/internal/web/static/base.css b/internal/web/static/base.css
index e7d23e8..b98f799 100644
--- a/internal/web/static/base.css
+++ b/internal/web/static/base.css
@@ -837,3 +837,20 @@ a.open-held[aria-expanded=false]::before { content: "▸ "; color: var(--muted);
.use .runs-now.short { color: var(--warn); }
.use .runs-now.bad { color: var(--bad); }
.use .runs-now .node { white-space: nowrap; }
+
+/* One line per record, in every table. A header never wraps; a cell of free
+ * text is cut to its column with the whole of it on hover, instead of a row of
+ * its own or a line break that makes one record look like two. */
+th { white-space: nowrap; }
+table.rows td { white-space: nowrap; }
+td.clip { max-width: 22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+table.rows td.clip { color: var(--muted); font-size: .85rem; }
+/* A row across the whole table is a note or an open form, not a record: it
+ * wraps as prose does. */
+table.rows td[colspan] { white-space: normal; }
+table.rows td.where { max-width: none; }
+table.rows .more { cursor: help; margin-left: .2rem; }
+table.explore td:first-child { max-width: 34rem; overflow: hidden; text-overflow: ellipsis; }
+table.explore .from { margin-left: .5rem; font-size: .78rem; color: var(--muted); }
+.clip-inline { display: inline-block; max-width: 24rem; overflow: hidden; text-overflow: ellipsis;
+ vertical-align: bottom; font-size: .8rem; color: var(--muted); margin-left: .4rem; }
diff --git a/internal/web/templates/account.html b/internal/web/templates/account.html
index 989b7db..7eef1f6 100644
--- a/internal/web/templates/account.html
+++ b/internal/web/templates/account.html
@@ -84,7 +84,7 @@ Tell whoever you asked for access that you have signed up.