Tidy the dashboard and the forms around it

- 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) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-09-28 18:08:16 +02:00
1 parent 5e1494f76d
commit c1926be869
8 files changed
+118 -66

No files matched your search

+17 -35
View File
@@ -33,15 +33,6 @@ func HBarChart(rows []Row, colour, unit string) template.HTML {
return template.HTML(`<p class="empty">Nothing recorded yet.</p>`) //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, `<div class="ranked"><svg viewBox="0 0 %.0f %.0f" role="img" `+
`preserveAspectRatio="xMinYMin meet" aria-label="ranked bars">`, 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(`<div class="ranked" role="list">`)
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, `<text x="0" y="%.1f" font-size="12" fill="var(%s)">%s</text>`,
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,
`<path d="M%.1f %.1f h%.1f a4 4 0 0 1 4 4 v%.1f a4 4 0 0 1 -4 4 h-%.1f z" fill="var(%s)">`+
`<title>%s: %s%s</title></path>`,
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, `<text x="%.1f" y="%.1f" font-size="12" fill="var(%s)">%s</text>`,
labelW+plotW+8, y+barH-2, cssVar("--muted"), escapeText(value))
fmt.Fprintf(&b, `<div class="rk" role="listitem" title="%s">`+
`<span class="rk-label">%s</span>`+
`<span class="rk-track"><i class="rk-bar" style="width:%.1f%%;background:var(%s)"></i></span>`+
`<span class="rk-value">%s</span></div>`,
escapeText(title), escapeText(r.Label), pct, cssVar(colour), escapeText(value))
}
b.WriteString(`</svg></div>`)
b.WriteString(`</div>`)
return template.HTML(b.String()) //nolint:gosec // every interpolation is escaped above
}
+22 -23
View File
@@ -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, "<path"); n != 2 {
// Two bars, one per row, and the tiny one has a width.
if n := strings.Count(out, `class="rk-bar"`); n != 2 {
t.Errorf("drew %d marks for two rows", n)
}
if strings.Contains(out, "width:0.0%") {
t.Error("the tiny value was drawn with no width")
}
}
func TestAnEmptyChartSaysSoRatherThanRenderingNothing(t *testing.T) {
@@ -47,13 +50,8 @@ func TestChartTextUsesTextColoursNotTheSeriesColour(t *testing.T) {
// identity. Colouring the text too makes the palette do two jobs and reads
// badly for anyone the hue separation was chosen to protect.
out := string(HBarChart([]Row{{Label: "a", Value: 5}}, "--viz-2", "req"))
for _, frag := range []string{`fill="var(--fg)"`, `fill="var(--muted)"`} {
if !strings.Contains(out, frag) {
t.Errorf("expected text in a text colour, got:\n%s", out)
}
}
if strings.Contains(out, `<text`) && strings.Contains(out, `fill="var(--viz-2)"><text`) {
t.Error("text was painted with the series colour")
if strings.Count(out, "var(--viz-2)") != 1 || !strings.Contains(out, `class="rk-bar"`) {
t.Errorf("the series colour should be on the bar and nowhere else:\n%s", out)
}
}
@@ -64,7 +62,7 @@ func TestEveryBarCarriesItsOwnHoverTitle(t *testing.T) {
out := string(HBarChart([]Row{
{Label: "a", Value: 1}, {Label: "b", Value: 2},
}, "--viz-1", "tokens"))
if n := strings.Count(out, "<title>"); 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, "<text"):strings.Index(out, "</text>")]
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")
}
}
+25
View File
@@ -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
}
+25 -1
View File
@@ -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; }
+2 -2
View File
@@ -69,7 +69,7 @@ Tell whoever you asked for access that you have signed up.</p>
{{end}}
<h2>Create a key</h2>
<form method="post" action="/profile/keys">
<form method="post" action="/profile/keys" class="narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>What is it for?<input type="text" name="name" placeholder="laptop, ci, notebook" required></label>
<button type="submit">Create</button>
@@ -100,7 +100,7 @@ old one to stop moving, then revoke it.</p>
{{if .PasswordChanged}}<p>Your password was changed. Any other browser you were
signed in on has been signed out.</p>{{end}}
{{if .PasswordError}}<p class="error">{{.PasswordError}}</p>{{end}}
<form method="post" action="/profile/password" autocomplete="on">
<form method="post" action="/profile/password" autocomplete="on" class="narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Current password<input type="password" name="current" required
autocomplete="current-password"></label>
+1 -1
View File
@@ -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. */}}
<p class="note error-row">No model has a price, so nothing is charged and every
<p class="banner-warn" role="note">No model has a price, so nothing is charged and every
budget below stays at zero however much is used. Rate and token limits are
unaffected — they count requests and tokens, not money. Set prices in the
<code>pricing</code> block of the server configuration to make budgets bind.</p>
+13 -4
View File
@@ -33,7 +33,15 @@
<td class="num">{{if .Unhealthy}}<span class="bad">{{.Unhealthy}}</span>{{else}}—{{end}}</td>
<td class="meter"><span style="width:{{pct .Used .Total}}%"></span><em>{{pct .Used .Total}}%</em></td>
</tr>{{end}}</table>
{{else}}<p class="empty">No GPUs have been reported.</p>{{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. */}}
<div class="capacity-cpu">
<div><b>{{.CPU.Ready}}/{{.CPU.Nodes}}</b> <span>nodes ready · CPU only</span></div>
<div><b>{{.CPU.MemoryGB}} GB</b> <span>memory</span></div>
{{if .CPU.DiskTotal}}<div><b>{{.CPU.DiskFreeGB}} of {{.CPU.DiskTotal}} GB</b> <span>free for weights</span></div>{{end}}
</div>
{{else}}<p class="empty">No nodes have registered.</p>{{end}}
{{if .Summary.Fabrics}}
<h2>Fabrics</h2>
@@ -84,7 +92,7 @@ for a model that needs this one.</p>
<td class="num">{{.Requests}}</td>
<td class="num">{{compact .Tokens}}</td>
<td class="num">{{if .Errors}}<span class="bad">{{.Errors}}</span>{{else}}—{{end}}</td>
<td class="num">{{printf "%.0f" .AvgLatency}} ms</td>
<td class="num">{{latency .AvgLatency}}</td>
</tr>{{end}}</table>
<p class="note">Counted by the model that actually answered, which differs from the one
requested whenever a fallback served it.</p>
@@ -97,7 +105,7 @@ requested whenever a fallback served it.</p>
<td><code>{{.ID}}</code>{{if .MgmtAddr}} <span class="note">{{.MgmtAddr}}</span>{{end}}
<a class="note" href="{{if $.OnAdminListener}}/nodes/{{.ID}}/logs{{else}}/admin/nodes/{{.ID}}/logs{{end}}">logs</a></td>
<td class="state state-{{.State}}">{{.State}}</td>
<td class="num">{{.GPUFree}}/{{.GPUTotal}}</td>
<td class="num">{{if .GPUTotal}}{{.GPUFree}}/{{.GPUTotal}}{{else}}—{{end}}</td>
<td class="num">{{if .MemoryGB}}{{.MemoryGB}}G{{else}}—{{end}}</td>
{{/* 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.</p>
{{end}}</table>
{{else}}<p class="empty">No agents have registered.</p>{{end}}
<h2>Models</h2>
{{with .Fleet}}
<h2>What the cluster is doing <span class="opt">last {{.Window}}</span></h2>
{{if .Err}}
@@ -138,6 +145,8 @@ requested whenever a fallback served it.</p>
{{end}}
{{end}}
<h2>Models</h2>
{{if .Models}}
<table><tr><th>Model</th><th>Aliases</th><th class="num">Ready</th><th class="num">Wanted</th><th>Profiles</th><th>Fallbacks</th></tr>
{{range .Models}}<tr>
+13
View File
@@ -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()
},