diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b2eb35..4ddf293e 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 27c3e260..0e5ba672 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 00000000..ad2ece9f --- /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 e7d23e85..b98f7997 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 989b7db5..7eef1f60 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.

{{end}} {{if .Fields}}

Settings to fill in

-{{range .Fields}} +
{{range .Fields}} {{end}}
{{.Label}}{{.Value}}
{{end}} @@ -149,7 +149,7 @@ document.addEventListener("click", (e) => {

Keys

{{if .Keys}} - +
NameKeySpends againstCreatedLast usedState
{{range .Keys}} @@ -192,7 +192,7 @@ old one to stop moving, then revoke it.

{{end}} {{if .Rows}} -
NameKeySpends againstCreatedLast usedState
{{.Name}} {{.Prefix}}
+
LimitValue
{{range .Rows}}{{end}}
LimitValue
{{.Name}}{{.Value}}
{{else}}

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 7574a7e3..a97349c0 100644 --- a/internal/web/templates/admin_mirror.html +++ b/internal/web/templates/admin_mirror.html @@ -34,7 +34,7 @@ reachable from this machine.

{{if .Rows}} - +
{{range .Groups}}{{$grouped := gt .Models 1}} {{if $grouped}} @@ -239,13 +239,13 @@ document.addEventListener('click', function (e) { {{if not .Repo}}{{with .Explore}} {{if .Rows}} -
ModelFilesSizeRuns
+
{{range .Rows}} + {{if or .BaseModel .License}}{{with .BaseModel}}from {{.}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}}{{end}} @@ -399,7 +399,7 @@ version and the old weights stay where they are.

{{end}} {{if .RepoSets}} -
RepositorySizeArchitectureContext DownloadsLikesUpdated
{{if .New}}new{{end}}{{if .Gated.Is}}gated{{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}}
+
{{range .RepoSets}}
WhatSizePublished 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}}
@@ -462,7 +462,7 @@ computes once it has it.

{{with .View.Reason}}

{{.}}

{{end}} {{if .Replicas}} - +
{{range .Replicas}}{{$rep := .}}{{range $i, $s := .Nodes}} diff --git a/internal/web/templates/admin_quotas.html b/internal/web/templates/admin_quotas.html index 41404e53..8eb195ef 100644 --- a/internal/web/templates/admin_quotas.html +++ b/internal/web/templates/admin_quotas.html @@ -34,7 +34,7 @@ budgets bind.

{{/* The tree is the interface. A quota belongs to a branch, so the form to set one opens on that branch rather than asking somebody to name it again in a box at the bottom. */}} -
ReplicaNodeStateAgeCPUMemory
{{if eq $i 0}}{{$rep.ID}}{{end}}
+
{{range .Rows}} @@ -119,7 +119,7 @@ up — hover it to see where. A value set here is in normal type.

A person's own limit covers everything they do, across all their keys, projects and the chat page. {{.Personal}}

{{if .People}} -
WhereRequests/minTokens/min BudgetSpent
+
{{range .People}} diff --git a/internal/web/templates/admin_usage.html b/internal/web/templates/admin_usage.html index 31ac345a..726dba10 100644 --- a/internal/web/templates/admin_usage.html +++ b/internal/web/templates/admin_usage.html @@ -47,7 +47,7 @@ {{if .Rows}} -
PersonRequests/minTokens/min BudgetSpent
+
{{range .Rows}} diff --git a/internal/web/templates/admin_users.html b/internal/web/templates/admin_users.html index 25eaf947..68072e51 100644 --- a/internal/web/templates/admin_users.html +++ b/internal/web/templates/admin_users.html @@ -22,7 +22,7 @@ {{if .Pending}}

Waiting for approval

-
GroupRequestsTokensSpendErrors
+
EmailNameSigned upApprove, and put them in
{{range .Pending}}
EmailNameSigned upApprove, and put them in
{{.Email}}{{.Name}}{{ago .CreatedAt}} @@ -149,14 +149,14 @@ project in the team — an account can only be approved into a project.

{{with index $.Members (printf "project:%s" $p.ID)}} - +
MemberStateLast sign-in
{{range .}}{{template "member-row" (dict "U" . "D" $ "Scope" (printf "project:%s" $p.ID) "Open" $p.ID)}}{{end}}
MemberStateLast sign-in
{{else}}

No members of its own. Members of its team or organization can use it too.

{{end}} {{template "add-member" (dict "Kind" "project" "ID" $p.ID "D" $ "Open" $p.ID)}} {{with index $.Keys $p.ID}} - +
KeyOwnerStateLast used
{{range .}} @@ -210,7 +210,7 @@ project in the team — an account can only be approved into a project.

All accounts

{{if .Accounts}} -
KeyOwnerStateLast used
{{.Name}} {{.Prefix}} {{or .OwnerEmail "issued by an operator"}}
+
PersonStateMember ofLast sign-inAdd to
{{range .Accounts}}{{template "account-row" (dict "U" . "D" $)}}{{end}}
PersonStateMember ofLast sign-inAdd to

Someone who is a member of nothing uses the gateway on their own: @@ -298,7 +298,7 @@ access than it had, so they need a shell on the control plane —

{{$u.Email}}{{if $u.IsAdmin}} admin{{end}} {{$u.State}} - {{range $u.Memberships}}{{.Path}}{{if eq .Role "manager"}} manager{{end}} + {{range $i, $m := $u.Memberships}}{{if lt $i 2}}{{with $m}}{{.Path}}{{if eq .Role "manager"}} manager{{end}} {{if eq (printf "%s" .Scope) "org"}} @@ -313,8 +313,12 @@ access than it had, so they need a shell on the control plane — - + {{end}}{{end}} {{else}}on their own{{end}} + {{/* Two, then a count: a person in six teams was six chips wrapping the + row into a paragraph. The rest are named on hover and in their own + scopes' member lists, where taking them out is done. */}} + {{if gt (len $u.Memberships) 2}}+{{sub (len $u.Memberships) 2}}{{end}} {{ago $u.LastLoginAt}} @@ -365,7 +369,7 @@ access than it had, so they need a shell on the control plane —
{{len (index $d.Members $k)}} member{{if ne (len (index $d.Members $k)) 1}}s{{end}}
{{with index $d.Members $k}} - +
{{range .}}{{template "member-row" (dict "U" . "D" $d "Scope" $k "Open" "")}}{{end}}
{{else}}

Nobody is a member of {{.Name}} itself; a member here could use every project in it.

{{end}} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index fc201c2d..116621df 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -51,23 +51,23 @@

Nodes

{{if .Nodes}} - +
NodeStateCPURAMGPUsDiskEnginesClassesFabricsModelsReplicasLast seen
{{if .AnyGPUs}}{{end}}{{if .AnyClasses}}{{end}}{{if .AnyFabrics}}{{end}} {{range .Nodes}} - {{/* A dash, not an empty bar, when the agent has not measured it lately: unknown is not idle. */}} - + {{if $.AnyGPUs}}{{end}} {{/* Free out of total, because the first is meaningless without the second, and an em dash where nothing was reported: an agent that was never asked about its disk must not read as a full one. */}} - - + {{if $.AnyClasses}}{{end}} + {{if $.AnyFabrics}}{{end}} @@ -80,18 +80,18 @@

Models

{{if .Models}} -
NodeStateCPURAMGPUsDiskEnginesClassesFabricsModelsReplicasSeen
{{.ID}}{{if .MgmtAddr}} {{.MgmtAddr}}{{end}} + {{shortNode .ID $.NodePrefix}}{{if .MgmtAddr}} {{.MgmtAddr}}{{end}} logs {{.State}}{{with .Load}}{{template "minibar" pctbar .CPUPercent}}{{printf "%.0f" .CPUPercent}}%{{if .CPUs}} of {{.CPUs}}{{end}}{{else}}—{{end}} {{with .Load}}{{template "minibar" pctbar .MemPercent}}{{bytes .MemUsed}} of {{bytes .MemTotal}}{{else}}{{if .MemoryGB}}{{.MemoryGB}}G{{else}}—{{end}}{{end}}{{if .GPUTotal}}{{.GPUFree}}/{{.GPUTotal}}{{else}}—{{end}}{{if .GPUTotal}}{{.GPUFree}}/{{.GPUTotal}}{{else}}—{{end}}{{if .DiskTotalGB}}{{.DiskFreeGB}}G/{{.DiskTotalGB}}G{{else}}—{{end}} {{if .Engines}}{{join .Engines}}{{else}}none yet{{end}}{{join .Classes}}{{join .Fabrics}}{{join .Classes}}{{join .Fabrics}}{{if .Models}}{{len .Models}}{{else}}—{{end}} {{.Replicas}} {{or .LastSeenAgo "—"}}
+
ModelAliasesReadyWantedProfilesFallbacks
{{if .AnyFallbacks}}{{end}} {{range .Models}} - + {{if $.AnyFallbacks}}{{end}} + {{/* Cut to the column, the whole sentence on hover: a row of its own for + each description made the table twice as long as the catalog. */}} + -{{/* Under the row rather than in a column: a sentence in a table cell either - wraps every other column into a stripe or gets cut off. */}} -{{if .Description}}{{end}} {{end}}
ModelAliasesReadyWantedProfilesFallbacksWhat it is for
{{.Name}} {{join .Aliases}} {{.Ready}} {{.Min}}–{{.Max}} {{join .Profiles}}{{join .Fallbacks}}{{join .Fallbacks}}{{or .Description "—"}}
{{.Description}}
{{else}}

The catalog is empty.

{{end}} @@ -106,7 +106,7 @@

Replicas

{{if .Replicas}} - +
ReplicaModelStatePoolEndpointAgeNodes
{{range .Replicas}} @@ -114,7 +114,7 @@ - + {{if .Err}}{{end}} {{end}}
ReplicaModelStatePoolEndpointAgeNodes
{{.ID}} {{.Model}}{{or .Pool "—"}} {{if .Endpoint}}{{.Endpoint}}{{else}}—{{end}} {{duration .AgeSec}}{{range .Shards}}{{.Node}} {{end}}{{range .Shards}}{{shortNode .Node $.NodePrefix}} {{end}}
{{.Err}}
{{else}}

No replicas.

{{end}} diff --git a/internal/web/templates/node.html b/internal/web/templates/node.html index ae541ba1..c6c5e8f4 100644 --- a/internal/web/templates/node.html +++ b/internal/web/templates/node.html @@ -35,7 +35,7 @@

Fabric

{{if .Node.Fabrics}}On {{join .Node.Fabrics}} · the map →{{else}}On no fabric: no port a declared fabric names can carry traffic, so no multi-node replica is placed here.{{end}}

{{if .Node.IBPorts}} - +
AdapterStateRateFabricPhysically onErrors since bootSentReceived
{{range .Node.IBPorts}} @@ -60,13 +60,13 @@ the adapter came up: a number that grows is the one to look at.

{{if or .Node.NVLinkDomain .Node.IMEX}} · IMEX {{if eq .Node.IMEX "active"}}active, {{.Node.IMEXChannels}} channel{{if ne .Node.IMEXChannels 1}}s{{end}}{{else}}{{or .Node.IMEX "not installed"}}{{end}}{{end}}

-
AdapterStateRateFabricPhysically onErrors since bootSentReceived
{{.HCA}}/{{.Port}}{{if eq .LinkLayer "Ethernet"}} RoCE{{end}} {{.State}}{{with .PhysState}} {{.}}{{end}}
+
{{range .Node.GPUs}}{{end}} {{range $a := .Node.GPUs}} {{range $b := $.Node.GPUs}}{{if eq $a.Index $b.Index}}{{else if hasInt $a.NVLinkPeers $b.Index}}{{else}}{{end}}{{end}} {{end}}
{{.Index}}
GPU {{$a.Index}}NV·
- +
{{range .Node.GPUs}} @@ -82,7 +82,7 @@ traffic between nodes should go: PIX and PXB are under the same PCIe switch, SYS

What runs here

{{if .Engines}} -
GPUNameNUMANearest adapterMulti-node NVLink
{{.Index}}{{.Name}}{{.NUMA}}
+
ModelReplicaStateCPUMemory
{{range .Engines}} diff --git a/internal/web/templates/org.html b/internal/web/templates/org.html index 5e62e092..b6c352a6 100644 --- a/internal/web/templates/org.html +++ b/internal/web/templates/org.html @@ -42,7 +42,7 @@ {{end}} {{if .Rows}} -
ModelReplicaStateCPUMemory
{{.Model}} {{.Replica}}
+
{{range .Rows}} @@ -56,7 +56,7 @@ people do elsewhere — in another organization, or on their own — is not show

Teams, projects and limits

{{if .Quotas}} -
{{if eq .By "user"}}Person{{else if eq .By "project"}}Project{{else}}Team{{end}} RequestsTokensSpendErrors
+
{{range .Quotas}} @@ -79,7 +79,7 @@ people do elsewhere — in another organization, or on their own — is not show

Members

{{if .Members}} -
WhereRequests/minTokens/min BudgetSpent · 30 days
+
{{range .Members}} diff --git a/internal/web/web.go b/internal/web/web.go index 21ca6051..692117e5 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -109,6 +109,8 @@ var funcs = template.FuncMap{ return p }, "ago": ago, + // shortNode is a node's name without what every node's name starts with. + "shortNode": strings.TrimPrefix, "age": age, // pctbar is a percentage as the minibar template takes it: whole, and // held to 0..100 so a measurement a little over does not draw past its box.
PersonMember ofState
{{.Email}}