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:
1 parent
5e1494f76d
commit
c1926be869
8 files changed
+118
-66
No files matched your search
+17
-35
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user