From c1926be869286a43cbfe9c03659f0de8c7118dfb Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Mon, 28 Sep 2026 18:08:16 +0200 Subject: [PATCH] Tidy the dashboard and the forms around it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Numeric column headers are right-aligned like their values; the stylesheet only aligned the cells, so "REQUESTS" sat nowhere near its numbers in every table that has them. - Ranked bars are an HTML grid instead of a fixed-width SVG: the value had 96 units and "34.2k tok 114 req" did not fit, so the figure a reader came for was the one cut off. Labels ellipsise in CSS and give their full text on hover. - Mean latency reads as 5 ms, 8.8 s or 1m 51s rather than 110695 ms. - The "What the cluster is doing" charts sat between the Models heading and the models table; they now come before it. - A fleet without GPUs shows its CPU capacity — nodes ready, memory, disk free for weights — instead of "No GPUs have been reported", and a node with no GPUs shows "—" rather than 0/0. - The profile's forms are a form's width rather than the page's, and the quotas page's price warning is a boxed banner rather than a line of red text. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/web/charts2.go | 52 ++++++++---------------- internal/web/charts2_test.go | 45 ++++++++++---------- internal/web/dashboard.go | 25 ++++++++++++ internal/web/static/base.css | 26 +++++++++++- internal/web/templates/account.html | 4 +- internal/web/templates/admin_quotas.html | 2 +- internal/web/templates/dashboard.html | 17 ++++++-- internal/web/web.go | 13 ++++++ 8 files changed, 118 insertions(+), 66 deletions(-) diff --git a/internal/web/charts2.go b/internal/web/charts2.go index 41e2bf38..3f918208 100644 --- a/internal/web/charts2.go +++ b/internal/web/charts2.go @@ -33,15 +33,6 @@ func HBarChart(rows []Row, colour, unit string) template.HTML { return template.HTML(`

Nothing recorded yet.

`) //nolint:gosec // constant } - const ( - rowH = 26.0 // the band - barH = 14.0 // the mark, capped well under the band so the rest is air - labelW = 190.0 - valueW = 96.0 - width = 720.0 - ) - height := float64(len(rows))*rowH + 8 - maxV := 0.0 for _, r := range rows { if r.Value > maxV { @@ -51,41 +42,32 @@ func HBarChart(rows []Row, colour, unit string) template.HTML { if maxV <= 0 { maxV = 1 } - plotW := width - labelW - valueW + // HTML rather than an SVG of fixed width. The SVG gave the value 96 units + // and "34.2k tok 114 req" did not fit, so the one figure a reader came for + // was the one cut off. A grid lets the label and the value take the room + // they need and gives the bar whatever is left. var b strings.Builder - fmt.Fprintf(&b, `
`, width, height) - - for i, r := range rows { - y := float64(i)*rowH + 4 - w := r.Value / maxV * plotW - if w < 2 && r.Value > 0 { + b.WriteString(`
`) + for _, r := range rows { + pct := r.Value / maxV * 100 + if pct < 0.5 && r.Value > 0 { // A value that rounds to nothing still happened, and a bar of zero // width reads as no data rather than as a small number. - w = 2 + pct = 0.5 } - - fmt.Fprintf(&b, `%s`, - y+barH-2, cssVar("--fg"), escapeText(trimLabel(r.Label, 30))) - - // Square at the baseline, rounded at the data end: the shape says which - // end is the measurement and which is the origin. - fmt.Fprintf(&b, - ``+ - `%s: %s%s`, - labelW, y, maxFloat(w-4, 0), barH-8, maxFloat(w-4, 0), - cssVar(colour), - escapeText(r.Label), escapeText(compact(r.Value)), escapeText(unitSuffix(unit))) - value := compact(r.Value) + unitSuffix(unit) + title := r.Label + ": " + value if r.Note != "" { - value += " " + r.Note + value += " · " + r.Note } - fmt.Fprintf(&b, `%s`, - labelW+plotW+8, y+barH-2, cssVar("--muted"), escapeText(value)) + fmt.Fprintf(&b, `
`+ + `%s`+ + ``+ + `%s
`, + escapeText(title), escapeText(r.Label), pct, cssVar(colour), escapeText(value)) } - b.WriteString(`
`) + b.WriteString(`
`) return template.HTML(b.String()) //nolint:gosec // every interpolation is escaped above } diff --git a/internal/web/charts2_test.go b/internal/web/charts2_test.go index fabb2212..00d51575 100644 --- a/internal/web/charts2_test.go +++ b/internal/web/charts2_test.go @@ -28,10 +28,13 @@ func TestASmallValueStillDrawsAMark(t *testing.T) { {Label: "huge", Value: 1_000_000}, {Label: "tiny", Value: 1}, }, "--viz-1", "")) - // Two paths, one per row: the tiny one is still drawn. - if n := strings.Count(out, ""); n != 2 { + if n := strings.Count(out, `title="`); n != 2 { t.Errorf("%d hover titles for two bars", n) } } @@ -72,17 +70,17 @@ func TestEveryBarCarriesItsOwnHoverTitle(t *testing.T) { func TestALongLabelIsTrimmedRatherThanOverflowing(t *testing.T) { long := strings.Repeat("a", 80) + "@example.com" out := string(HBarChart([]Row{{Label: long, Value: 1}}, "--viz-1", "")) - // Trimmed where it is drawn, in full where it is hovered: the plot keeps - // its geometry and the reader can still find out whose row it is. - drawn := out[strings.Index(out, "")] - if strings.Contains(drawn, long) { - t.Error("a very long label was drawn in full and would overrun the plot") + // Cut where it is drawn — the label cell ellipsises in CSS — and in full + // where it is hovered, so the reader can still find out whose row it is. + if !strings.Contains(out, `title="`+long+`: `) { + t.Error("the full label is not available on hover") } - if !strings.Contains(out, long) { - t.Error("the full label is not available anywhere, not even on hover") + css, err := staticFiles.ReadFile("static/base.css") + if err != nil { + t.Fatal(err) } - if !strings.Contains(out, "…") { - t.Error("the label was cut without saying it had been") + if !strings.Contains(string(css), ".ranked .rk-label { font-size: .82rem; overflow: hidden; text-overflow: ellipsis;") { + t.Error("the label cell no longer cuts a long label with an ellipsis") } } @@ -108,11 +106,12 @@ func TestTheChartScalesToItsContainer(t *testing.T) { // The dashboard is read on laptops and phones. A fixed pixel width is how a // table ends up scrolling sideways. out := string(HBarChart([]Row{{Label: "a", Value: 1}}, "", "")) - if !strings.Contains(out, "viewBox") { - t.Error("no viewBox, so the chart cannot scale") + // Bars are a share of whatever width the grid gives them, never pixels. + if !strings.Contains(out, "width:100.0%") { + t.Errorf("the bar is not sized as a share of its track:\n%s", out) } - if strings.Contains(out, `width="720"`) { - t.Error("a fixed pixel width was set on the chart") + if strings.Contains(out, "px") { + t.Error("a fixed pixel size was set on the chart") } } diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go index 110619cc..055899b9 100644 --- a/internal/web/dashboard.go +++ b/internal/web/dashboard.go @@ -14,6 +14,9 @@ import ( // DashboardData is the whole page. type DashboardData struct { + // CPU is the fleet's capacity when it has no GPUs, which is shown instead + // of a GPU table that would only say there are none. + CPU cpuCapacity // Menu is the shared top menu; Nav below is what the cluster page adds. Menu Nav Nodes []adminapi.NodeView @@ -152,6 +155,7 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat } if src != nil { d.Nodes = src.Nodes() + d.CPU = cpuCapacityOf(d.Nodes) d.Models = src.Models() d.Replicas = src.Replicas("") d.NodeCount = len(d.Nodes) @@ -321,3 +325,24 @@ func unplaced(src Source, models []adminapi.ModelView) []adminapi.ExplainView { } return out } + +// cpuCapacity is what a fleet without GPUs has to offer. +type cpuCapacity struct { + Nodes, Ready int + MemoryGB int + DiskFreeGB, DiskTotal int +} + +func cpuCapacityOf(nodes []adminapi.NodeView) cpuCapacity { + var c cpuCapacity + for _, n := range nodes { + c.Nodes++ + if n.State == "ready" { + c.Ready++ + } + c.MemoryGB += n.MemoryGB + c.DiskFreeGB += n.DiskFreeGB + c.DiskTotal += n.DiskTotalGB + } + return c +} diff --git a/internal/web/static/base.css b/internal/web/static/base.css index 0ea359ea..02f45ef7 100644 --- a/internal/web/static/base.css +++ b/internal/web/static/base.css @@ -37,7 +37,7 @@ th, td { text-align: left; padding: .5rem .7rem; border-bottom: 1px solid var(-- th { font-weight: 600; color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; } tr:last-child td { border-bottom: none; } -td.num { text-align: right; font-variant-numeric: tabular-nums; } +td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; } pre { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .9rem 1rem; overflow-x: auto; } @@ -372,3 +372,27 @@ nav.menu a[aria-current="page"] { color: var(--fg); font-weight: 600; nav.menu .menu-gap { flex: 1 1 auto; min-width: .5rem; } nav.menu form.inline button.link { font-size: .9rem; text-decoration: none; color: var(--muted); } nav.menu form.inline button.link:hover { color: var(--fg); text-decoration: underline; } + +/* Ranked bars: label, bar, value, each taking the room it needs. */ +.ranked { display: grid; grid-template-columns: minmax(7rem, max-content) minmax(6rem, 1fr) max-content; + gap: .35rem .8rem; align-items: center; max-width: 100%; } +.ranked .rk { display: contents; } +.ranked .rk-label { font-size: .82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-width: 18rem; } +.ranked .rk-track { height: .7rem; } +.ranked .rk-bar { display: block; height: 100%; border-radius: 0 4px 4px 0; min-width: 2px; } +.ranked .rk-value { font-size: .8rem; color: var(--muted); font-variant-numeric: tabular-nums; + white-space: nowrap; } + +/* A warning about the page as a whole, set apart from the notes around it. */ +.banner-warn { border: 1px solid var(--warn); border-left-width: 4px; border-radius: 6px; + background: var(--card); padding: .6rem .8rem; margin: 0 0 1rem; + font-size: .88rem; color: var(--fg); } + +/* Single-column forms read as a form, not as a row of the page. */ +form.narrow, .narrow-form form { max-width: 30rem; } + +/* CPU-only capacity: one line of figures in place of a GPU table. */ +.capacity-cpu { display: flex; flex-wrap: wrap; gap: .4rem 1.6rem; margin: .2rem 0 1rem; } +.capacity-cpu b { font-size: 1.15rem; font-variant-numeric: tabular-nums; } +.capacity-cpu span { color: var(--muted); font-size: .85rem; } diff --git a/internal/web/templates/account.html b/internal/web/templates/account.html index 3e057bcf..2c1df488 100644 --- a/internal/web/templates/account.html +++ b/internal/web/templates/account.html @@ -69,7 +69,7 @@ Tell whoever you asked for access that you have signed up.

{{end}}

Create a key

-
+ @@ -100,7 +100,7 @@ old one to stop moving, then revoke it.

{{if .PasswordChanged}}

Your password was changed. Any other browser you were signed in on has been signed out.

{{end}} {{if .PasswordError}}

{{.PasswordError}}

{{end}} - + diff --git a/internal/web/templates/admin_quotas.html b/internal/web/templates/admin_quotas.html index ac734267..481cf170 100644 --- a/internal/web/templates/admin_quotas.html +++ b/internal/web/templates/admin_quotas.html @@ -14,7 +14,7 @@ prices every request costs nothing, so these numbers read as limits and bind on nothing. Self-hosted models having no per-token price is ordinary; showing somebody a budget without saying so is not. */}} -

No model has a price, so nothing is charged and every +

diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 17f924f2..e2b55af1 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -33,7 +33,15 @@ {{if .Unhealthy}}{{.Unhealthy}}{{else}}—{{end}} {{pct .Used .Total}}% {{end}} -{{else}}

No GPUs have been reported.

{{end}} +{{else if .CPU.Nodes}} +{{/* No GPUs is not no capacity: a CPU fleet still has memory and disk, + which is what decides what it can hold. */}} +
+
{{.CPU.Ready}}/{{.CPU.Nodes}} nodes ready · CPU only
+
{{.CPU.MemoryGB}} GB memory
+ {{if .CPU.DiskTotal}}
{{.CPU.DiskFreeGB}} of {{.CPU.DiskTotal}} GB free for weights
{{end}} +
+{{else}}

No nodes have registered.

{{end}} {{if .Summary.Fabrics}}

Fabrics

@@ -84,7 +92,7 @@ for a model that needs this one.

{{.Requests}} {{compact .Tokens}} {{if .Errors}}{{.Errors}}{{else}}—{{end}} - {{printf "%.0f" .AvgLatency}} ms + {{latency .AvgLatency}} {{end}}

Counted by the model that actually answered, which differs from the one requested whenever a fallback served it.

@@ -97,7 +105,7 @@ requested whenever a fallback served it.

{{.ID}}{{if .MgmtAddr}} {{.MgmtAddr}}{{end}} logs {{.State}} - {{.GPUFree}}/{{.GPUTotal}} + {{if .GPUTotal}}{{.GPUFree}}/{{.GPUTotal}}{{else}}—{{end}} {{if .MemoryGB}}{{.MemoryGB}}G{{else}}—{{end}} {{/* Free out of total, because the first is meaningless without the second, and an em dash where nothing was reported: an agent that @@ -115,7 +123,6 @@ requested whenever a fallback served it.

{{end}} {{else}}

No agents have registered.

{{end}} -

Models

{{with .Fleet}}

What the cluster is doing last {{.Window}}

{{if .Err}} @@ -138,6 +145,8 @@ requested whenever a fallback served it.

{{end}} {{end}} +

Models

+ {{if .Models}} {{range .Models}} diff --git a/internal/web/web.go b/internal/web/web.go index d955bb3a..fb740232 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -44,6 +44,19 @@ var funcs = template.FuncMap{ } return strings.Join(v, ", ") }, + // latency writes a mean latency the way a person reads one: 5 ms, 8.8 s, + // 1m 51s — not 110695 ms. + "latency": func(ms float64) string { + switch { + case ms < 1000: + return fmt.Sprintf("%.0f ms", ms) + case ms < 60_000: + return fmt.Sprintf("%.1f s", ms/1000) + default: + d := time.Duration(ms) * time.Millisecond + return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60) + } + }, "duration": func(sec int64) string { return time.Duration(sec * int64(time.Second)).Round(time.Second).String() },
ModelAliasesReadyWantedProfilesFallbacks