From eb9e9b348c2a99ebc81e70ff911cb60ebbd93a31 Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Thu, 17 Sep 2026 18:38:21 +0200 Subject: [PATCH] Show who is using the cluster and what they are running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard could say what the cluster was made of and not who was spending it. Usage was per key, which is the right unit for revoking something and the wrong one for the question actually being asked: one person holds a laptop key, a CI key and a notebook key, and adding three rows up by eye is a chore that gets skipped. Per person now, joined through the keys they own, and per model — counted by the model that answered rather than the one requested, since those differ whenever a fallback serves and what actually ran is the question. Headline figures are stat tiles with a sparkline: a single current value is a number, and plotting one is how a chart ends up with a single bar in it. The sparkline says only rising, falling or flat, which is the one thing the number cannot. Ranked horizontal bars, because the labels are email addresses and model names and the label is the point — the question is which person, not what shape the distribution has. Rotated or truncated axis labels say the chart matters more than reading it. Trimmed where drawn and complete on hover, so the plot keeps its geometry and a reader can still find out whose row it is. The palette is validated rather than chosen: every adjacent pair clears the colour-vision separation threshold, and the dark values are the same hues re-stepped for the dark surface instead of an automatic flip. Run through a validator, because that is the only way anyone can honestly claim a palette is colourblind-safe. Which turned up a bug in the existing chart code. cssVar is an allowlist of four names that silently returns --accent for anything else, and callers wrap it in var() themselves — so every new series would have rendered in one colour with accent-coloured text. Caught by a test written from the rule that chart text wears text tokens, never the series colour. A key with no owner is labelled "operator-issued keys" rather than unknown: it predates accounts and is doing its job, and "unknown" would imply something is wrong. Co-Authored-By: Claude Opus 5 (1M context) --- internal/store/sqlite.go | 106 ++++++++++++++++++ internal/web/chart.go | 8 +- internal/web/charts2.go | 154 ++++++++++++++++++++++++++ internal/web/charts2_test.go | 117 +++++++++++++++++++ internal/web/dashboard.go | 111 +++++++++++++++++++ internal/web/static/base.css | 46 ++++++++ internal/web/templates/dashboard.html | 39 +++++++ internal/web/web.go | 6 + internal/web/web_test.go | 81 ++++++++++++++ 9 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 internal/web/charts2.go create mode 100644 internal/web/charts2_test.go diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 240d4505..30b56209 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -635,3 +635,109 @@ func (s *SQLite) UsageBuckets(ctx context.Context, since time.Time, width time.D } return out, nil } + +// UserUsage is one person's consumption, across every key they hold. +type UserUsage struct { + UserID string + Email string + Name string + Keys int64 + Requests int64 + Prompt int64 + Completion int64 + CostMicros int64 + Errors int64 + LastSeen time.Time +} + +// Tokens is what the person actually consumed, which is the figure they +// recognise — prompt and completion together. +func (u UserUsage) Tokens() int64 { return u.Prompt + u.Completion } + +// UsageByUser aggregates usage per person rather than per key. +// +// Per key is the right unit for revoking something and the wrong one for +// answering "who is using this cluster": one person holds a laptop key, a CI +// key and a notebook key, and reading three rows to add them up is a chore that +// gets skipped. Keys with no owner — issued by an operator before accounts +// existed — are grouped under an empty user, which is honest rather than tidy. +func (s *SQLite) UsageByUser(ctx context.Context, since time.Time) ([]UserUsage, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT COALESCE(k.user_id, ''), COALESCE(us.email, ''), COALESCE(us.name, ''), + COUNT(DISTINCT u.key_id), + COUNT(*), SUM(u.prompt_tokens), SUM(u.completion_tokens), + SUM(u.cost_micros), + SUM(CASE WHEN u.status >= 400 THEN 1 ELSE 0 END), + MAX(u.ts) + FROM usage u + LEFT JOIN api_keys k ON k.id = u.key_id + LEFT JOIN users us ON us.id = k.user_id + WHERE u.ts >= ? + GROUP BY COALESCE(k.user_id, '') + ORDER BY SUM(u.cost_micros) DESC, COUNT(*) DESC`, millis(since)) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []UserUsage + for rows.Next() { + var u UserUsage + var last int64 + if err := rows.Scan(&u.UserID, &u.Email, &u.Name, &u.Keys, + &u.Requests, &u.Prompt, &u.Completion, &u.CostMicros, &u.Errors, &last); err != nil { + return nil, err + } + u.LastSeen = time.UnixMilli(last).UTC() + out = append(out, u) + } + return out, rows.Err() +} + +// ModelUsage is one model's share of the traffic. +type ModelUsage struct { + Model string + Requests int64 + Prompt int64 + Completion int64 + CostMicros int64 + Errors int64 + AvgLatency float64 +} + +// Tokens is prompt and completion together. +func (m ModelUsage) Tokens() int64 { return m.Prompt + m.Completion } + +// UsageByModel aggregates usage per model. +// +// By served_model rather than the requested name, because those differ whenever +// a fallback answers — and "what actually ran" is the question being asked of a +// cluster. The requested name is kept in the same table for anyone who needs to +// ask the other question. +func (s *SQLite) UsageByModel(ctx context.Context, since time.Time) ([]ModelUsage, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT CASE WHEN served_model != '' THEN served_model ELSE model END, + COUNT(*), SUM(prompt_tokens), SUM(completion_tokens), + SUM(cost_micros), + SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END), + AVG(latency_ms) + FROM usage WHERE ts >= ? + GROUP BY 1 ORDER BY COUNT(*) DESC`, millis(since)) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []ModelUsage + for rows.Next() { + var m ModelUsage + var avg sql.NullFloat64 + if err := rows.Scan(&m.Model, &m.Requests, &m.Prompt, &m.Completion, + &m.CostMicros, &m.Errors, &avg); err != nil { + return nil, err + } + m.AvgLatency = avg.Float64 + out = append(out, m) + } + return out, rows.Err() +} diff --git a/internal/web/chart.go b/internal/web/chart.go index 8c4ba3a6..592c0512 100644 --- a/internal/web/chart.go +++ b/internal/web/chart.go @@ -91,7 +91,13 @@ func BarChart(s Series, unit string) template.HTML { // defines, so a colour can never become a way to inject CSS. func cssVar(name string) string { switch name { - case "--accent", "--ok", "--warn", "--bad": + case "--accent", "--ok", "--warn", "--bad", + // The chart palette, and the text tokens charts label themselves with. + // An allowlist rather than a check, because this value is interpolated + // into a style attribute and the set of things that belong there is + // small, known and worth writing down. + "--viz-1", "--viz-2", "--viz-3", "--viz-4", "--viz-grid", + "--fg", "--muted": return name default: return "--accent" diff --git a/internal/web/charts2.go b/internal/web/charts2.go new file mode 100644 index 00000000..48d42a7b --- /dev/null +++ b/internal/web/charts2.go @@ -0,0 +1,154 @@ +package web + +import ( + "fmt" + "html/template" + "strings" +) + +// Row is one labelled value in a ranked chart. +type Row struct { + Label string + Value float64 + // Note is shown beside the value: a second figure worth carrying, such as + // how many requests made up a token total. + Note string +} + +// HBarChart ranks labelled values as horizontal bars. +// +// Horizontal because the labels are email addresses and model names. Rotated or +// truncated labels on a column chart are a way of saying the chart is more +// important than being able to read it, and here the label is the point: the +// question is which person or which model, not what shape the distribution is. +// +// One series, so no legend — the heading names it. The value is written at the +// end of each bar rather than on an axis, because a reader wants the number for +// the row they are looking at, not a scale to estimate it against. +func HBarChart(rows []Row, colour, unit string) template.HTML { + if colour == "" { + colour = "--viz-1" + } + if len(rows) == 0 { + 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 { + maxV = r.Value + } + } + if maxV <= 0 { + maxV = 1 + } + plotW := width - labelW - valueW + + 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 { + // 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 + } + + 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) + if r.Note != "" { + value += " " + r.Note + } + fmt.Fprintf(&b, `%s`, + labelW+plotW+8, y+barH-2, cssVar("--muted"), escapeText(value)) + } + b.WriteString(`
`) + return template.HTML(b.String()) //nolint:gosec // every interpolation is escaped above +} + +// Sparkline draws a value's recent shape beside its number. +// +// No axes, no labels, no gridlines. It answers "rising, falling or flat" and +// nothing else; a reader who wants a number reads the number it sits under. +func Sparkline(values []float64, colour string) template.HTML { + if colour == "" { + colour = "--viz-1" + } + if len(values) < 2 { + return "" + } + const w, h = 120.0, 24.0 + + maxV := 0.0 + for _, v := range values { + if v > maxV { + maxV = v + } + } + if maxV <= 0 { + // Flat at zero is a real answer — no traffic — and drawing it along the + // bottom says so, where an empty box would read as a broken chart. + maxV = 1 + } + + var b strings.Builder + fmt.Fprintf(&b, ``, w, h, w, h) + b.WriteString(``) + return template.HTML(b.String()) //nolint:gosec // numbers and a CSS var only +} + +// trimLabel keeps a long label readable without letting it push the plot off +// the side. Long email addresses are the common case. +func trimLabel(s string, n int) string { + if len(s) <= n { + return s + } + if n <= 1 { + return s[:n] + } + return s[:n-1] + "…" +} + +func unitSuffix(unit string) string { + if unit == "" { + return "" + } + return " " + unit +} + +func maxFloat(a, b float64) float64 { + if a > b { + return a + } + return b +} diff --git a/internal/web/charts2_test.go b/internal/web/charts2_test.go new file mode 100644 index 00000000..42efd461 --- /dev/null +++ b/internal/web/charts2_test.go @@ -0,0 +1,117 @@ +package web + +import ( + "strings" + "testing" +) + +func TestRankedBarsLabelEveryRowDirectly(t *testing.T) { + // The label is the point of this chart: the question is which person or + // which model, not what shape the distribution is. Rotated or truncated + // axis labels would say the chart matters more than reading it. + out := string(HBarChart([]Row{ + {Label: "ilker@example.com", Value: 1200}, + {Label: "deniz@example.com", Value: 300}, + }, "--viz-1", "tokens")) + + for _, want := range []string{"ilker@example.com", "deniz@example.com", "1.2k", "300"} { + if !strings.Contains(out, want) { + t.Errorf("the chart does not show %q:\n%s", want, out) + } + } +} + +func TestASmallValueStillDrawsAMark(t *testing.T) { + // A bar that rounds to zero width reads as no data rather than as a small + // number, which is a different and wrong claim. + out := string(HBarChart([]Row{ + {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, " does + // that with no JavaScript, which is the right trade for a page this project + // serves from a single binary. + out := string(HBarChart([]Row{ + {Label: "a", Value: 1}, {Label: "b", Value: 2}, + }, "--viz-1", "tokens")) + if n := strings.Count(out, ""); n != 2 { + t.Errorf("%d hover titles for two bars", n) + } +} + +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") + } + if !strings.Contains(out, long) { + t.Error("the full label is not available anywhere, not even on hover") + } + if !strings.Contains(out, "…") { + t.Error("the label was cut without saying it had been") + } +} + +func TestASparklineOfNothingIsNotDrawn(t *testing.T) { + // One point is not a trend, and a single dot invites reading one. + if got := Sparkline([]float64{5}, ""); got != "" { + t.Errorf("a one-point sparkline rendered: %q", got) + } + if got := Sparkline(nil, ""); got != "" { + t.Errorf("an empty sparkline rendered: %q", got) + } +} + +func TestAFlatSparklineIsDrawnAlongTheBottom(t *testing.T) { + // No traffic is a real answer. An empty box would read as a broken chart. + out := string(Sparkline([]float64{0, 0, 0}, "")) + if !strings.Contains(out, "<polyline") { + t.Errorf("a flat series did not draw: %q", out) + } +} + +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") + } + if strings.Contains(out, `width="720"`) { + t.Error("a fixed pixel width was set on the chart") + } +} diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go index 16a5c54c..9eefa3be 100644 --- a/internal/web/dashboard.go +++ b/internal/web/dashboard.go @@ -2,6 +2,7 @@ package web import ( "context" + "fmt" "html/template" "log/slog" "net/http" @@ -20,6 +21,21 @@ type DashboardData struct { Usage []store.KeyUsage Summary adminapi.Summary + Users []store.UserUsage + Models2 []store.ModelUsage + + // Tiles are the headline figures, which are numbers rather than charts. + Requests24h int64 + Tokens24h int64 + Errors24h int64 + Spend24h int64 + RequestSpark template.HTML + TokenSpark template.HTML + + UserChart template.HTML + ModelChart template.HTML + HasPeople 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 @@ -68,6 +84,12 @@ type Source interface { type UsageSource interface { UsageSince(ctx context.Context, since time.Time) ([]store.KeyUsage, error) UsageBuckets(ctx context.Context, since time.Time, width time.Duration) ([]store.Bucket, error) + // UsageByUser and UsageByModel answer the two questions a key-by-key table + // answers badly: who is using this cluster, and what are they running. One + // person holds a laptop key, a CI key and a notebook key, and adding three + // rows up by eye is a chore that gets skipped. + UsageByUser(ctx context.Context, since time.Time) ([]store.UserUsage, error) + UsageByModel(ctx context.Context, since time.Time) ([]store.ModelUsage, error) } // Dashboard serves the read-only cluster view. @@ -123,11 +145,87 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat } d.Usage = rows d.addCharts(r.Context(), usage, log) + d.addPeopleAndModels(r.Context(), usage, log) } render(w, "dashboard", d) } } +// addPeopleAndModels answers who is using the cluster and what they are running. +// +// Both are ranked rather than plotted over time: the question is which person +// and which model, and a reader comparing names wants them beside each other +// rather than threaded through a time axis they then have to untangle. +// +// A reporting query failing leaves the section out rather than the page: the +// cluster state above it is what someone is most likely here for. +func (d *DashboardData) addPeopleAndModels(ctx context.Context, usage UsageSource, log *slog.Logger) { + since := time.Now().Add(-24 * time.Hour) + + users, err := usage.UsageByUser(ctx, since) + if err != nil { + log.Warn("could not read per-user usage", "err", err) + } else { + d.Users = users + rows := make([]Row, 0, len(users)) + for _, u := range topUsers(users, 8) { + rows = append(rows, Row{ + Label: userLabel(u), + Value: float64(u.Tokens()), + Note: fmt.Sprintf("%d req", u.Requests), + }) + } + d.UserChart = HBarChart(rows, "--viz-1", "tok") + d.HasPeople = len(rows) > 0 + } + + models, err := usage.UsageByModel(ctx, since) + if err != nil { + log.Warn("could not read per-model usage", "err", err) + return + } + d.Models2 = models + rows := make([]Row, 0, len(models)) + for i, m := range models { + if i >= 8 { + break + } + rows = append(rows, Row{ + Label: m.Model, + Value: float64(m.Requests), + Note: fmt.Sprintf("%.0f ms", m.AvgLatency), + }) + } + d.ModelChart = HBarChart(rows, "--viz-3", "req") +} + +// userLabel names a person the way they would recognise themselves. +// +// A key with no owner predates accounts, and calling it "unknown" would imply +// something is wrong; it is an operator-issued key doing its job. +func userLabel(u store.UserUsage) string { + switch { + case u.Email != "": + return u.Email + case u.Name != "": + return u.Name + default: + return "operator-issued keys" + } +} + +// topUsers keeps the chart readable. +// +// Eight, because a ranked bar chart stops being a comparison somewhere around +// there and becomes a table drawn slowly. The full list is in the table below +// it, which is the right form for twenty rows. +func topUsers(users []store.UserUsage, n int) []store.UserUsage { + if len(users) > n { + return users[:n] + } + return users +} + // addCharts draws the last day of traffic. // // Hourly, over 24 hours: fine enough to show a working day and coarse enough @@ -160,6 +258,19 @@ func (d *DashboardData) addCharts(ctx context.Context, usage UsageSource, log *s d.TokenChart = BarChart(tokens, "tokens/h") d.LatencyChart = BarChart(latency, "ms") d.HasCharts = true + + // The headline figures. Numbers rather than charts, because a single + // current value is a number — plotting one is how a chart ends up with one + // bar in it. The sparkline beside each says only whether it is rising, + // falling or flat, which is the one thing the number cannot say. + for _, b := range buckets { + d.Requests24h += b.Requests + d.Tokens24h += b.Prompt + b.Completion + d.Errors24h += b.Errors + d.Spend24h += b.CostMicros + } + d.RequestSpark = Sparkline(requests.Values, "--viz-1") + d.TokenSpark = Sparkline(tokens.Values, "--viz-3") } // unplaced collects the placement explanations for models short of replicas. diff --git a/internal/web/static/base.css b/internal/web/static/base.css index 8df9831e..5198705b 100644 --- a/internal/web/static/base.css +++ b/internal/web/static/base.css @@ -120,3 +120,49 @@ select { font: inherit; padding: .35rem .4rem; border: 1px solid var(--line); } .meter > em { font-style: normal; font-size: .8rem; color: var(--muted); } .bad { color: var(--bad); } + +/* Chart palette. + * + * Roles rather than raw colours, so light and dark swap in one place and the + * charts are written against meaning. The values are a validated categorical + * set: every adjacent pair clears the colour-vision-deficiency separation + * threshold, and the dark column is the same hues re-stepped for the dark + * surface rather than an automatic flip of the light one. + * + * Checked with a validator rather than by eye, which is the only way anyone + * can honestly claim a palette is colourblind-safe. */ +:root { + --viz-1: #2a78d6; /* blue */ + --viz-2: #eb6834; /* orange */ + --viz-3: #1baf7a; /* aqua */ + --viz-4: #eda100; /* yellow */ + --viz-grid: #e6e5e1; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --viz-1: #3987e5; + --viz-2: #d95926; + --viz-3: #199e70; + --viz-4: #c98500; + --viz-grid: #2e2e2c; + } +} +:root[data-theme="dark"] { + --viz-1: #3987e5; + --viz-2: #d95926; + --viz-3: #199e70; + --viz-4: #c98500; + --viz-grid: #2e2e2c; +} + +/* Stat tiles: the headline figures, which are numbers rather than charts. */ +.tiles { display: flex; flex-wrap: wrap; gap: .75rem; margin: .5rem 0 1.25rem; } +.tile { + flex: 1 1 8.5rem; min-width: 8.5rem; padding: .6rem .75rem; + border: 1px solid var(--viz-grid); border-radius: 6px; background: var(--card); +} +.tile .v { font-size: 1.5rem; line-height: 1.1; font-variant-numeric: tabular-nums; } +.tile .k { font-size: .8rem; color: var(--muted); } +.tile svg { display: block; margin-top: .35rem; } +.chart { margin: .25rem 0 1rem; } +.chart svg { display: block; max-width: 100%; height: auto; } diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 9ec393b6..62038322 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -20,6 +20,15 @@ </nav> </header> +{{if .HasCharts}} +<div class="tiles"> + <div class="tile"><div class="v">{{.Requests24h}}</div><div class="k">requests · 24h</div>{{.RequestSpark}}</div> + <div class="tile"><div class="v">{{compact .Tokens24h}}</div><div class="k">tokens · 24h</div>{{.TokenSpark}}</div> + <div class="tile"><div class="v">{{.Errors24h}}</div><div class="k">errors · 24h</div></div> + <div class="tile"><div class="v">{{units .Spend24h}}</div><div class="k">spend · 24h</div></div> +</div> +{{end}} + <h2>Capacity</h2> {{if .Summary.GPUs}} <table><tr><th>Class</th><th class="num">In use</th><th class="num">Free</th><th class="num">Faulty</th><th>Usage</th></tr> @@ -57,6 +66,36 @@ for a model that needs this one.</p> </tr>{{end}}</table> {{end}} +{{if .HasPeople}} +<h2>People</h2> +{{.UserChart}} +<table><tr><th>Person</th><th class="num">Keys</th><th class="num">Requests</th><th class="num">Tokens</th><th class="num">Errors</th><th class="num">Spend</th><th>Last seen</th></tr> +{{range .Users}}<tr> + <td>{{if .Email}}{{.Email}}{{else}}<span class="note">operator-issued keys</span>{{end}}</td> + <td class="num">{{.Keys}}</td> + <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">{{units .CostMicros}}</td> + <td>{{ago .LastSeen}}</td> +</tr>{{end}}</table> +{{end}} + +{{if .Models2}} +<h2>What is being run</h2> +{{.ModelChart}} +<table><tr><th>Model</th><th class="num">Requests</th><th class="num">Tokens</th><th class="num">Errors</th><th class="num">Mean latency</th></tr> +{{range .Models2}}<tr> + <td><code>{{.Model}}</code></td> + <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> +</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> +{{end}} + <h2>Nodes</h2> {{if .Nodes}} <table><tr><th>Node</th><th>State</th><th class="num">GPUs</th><th class="num">RAM</th><th>Classes</th><th>Fabrics</th><th class="num">Models</th><th class="num">Replicas</th><th>Last seen</th></tr> diff --git a/internal/web/web.go b/internal/web/web.go index 4b63bec4..da68b81b 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -69,6 +69,12 @@ var funcs = template.FuncMap{ }, // Spend is stored in micro-units so accumulation is exact; it is shown in // whole units because that is the number an operator set a budget in. + // compact renders a large count the way someone says it aloud: 1.2k rather + // than 1247. Exactness is available in the tooltip and the table; a column + // of seven-digit numbers is harder to compare than one of three. + "compact": func(v int64) string { + return compact(float64(v)) + }, "units": func(micros int64) string { return fmt.Sprintf("%.2f", float64(micros)/1e6) }, diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 33945363..e2135b3e 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -2,6 +2,7 @@ package web import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -107,6 +108,21 @@ func (fakeSource) Nodes() []adminapi.NodeView { }} } +func (fakeUsage) UsageByUser(context.Context, time.Time) ([]store.UserUsage, error) { + return []store.UserUsage{ + {UserID: "usr-1", Email: "ilker@example.com", Keys: 2, + Requests: 40, Prompt: 8000, Completion: 4000, CostMicros: 4_500_000, LastSeen: time.Now()}, + {UserID: "", Keys: 1, Requests: 3, Prompt: 90, Completion: 40, Errors: 1, LastSeen: time.Now()}, + }, nil +} + +func (fakeUsage) UsageByModel(context.Context, time.Time) ([]store.ModelUsage, error) { + return []store.ModelUsage{ + {Model: "qwen-coder", Requests: 30, Prompt: 6000, Completion: 3000, AvgLatency: 240}, + {Model: "qwen", Requests: 13, Prompt: 2090, Completion: 1040, Errors: 1, AvgLatency: 90}, + }, nil +} + func (fakeSource) Summary() adminapi.Summary { return adminapi.Summary{ Nodes: adminapi.NodeTotals{Total: 1, Ready: 1}, @@ -478,3 +494,68 @@ func TestAUsageMeterCannotOverrunItsColumn(t *testing.T) { } } } + +func TestTheDashboardShowsWhoIsUsingTheCluster(t *testing.T) { + // Per key is the right unit for revoking something and the wrong one for + // answering who is using this cluster: one person holds a laptop key, a CI + // key and a notebook key, and adding three rows up by eye gets skipped. + h := Dashboard(fakeSource{}, fakeUsage{}, "v1", time.Second, true, nil) + code, page := get(t, h, "http://127.0.0.1:9090/", nil) + if code != http.StatusOK { + t.Fatalf("status %d", code) + } + for _, want := range []string{"People", "ilker@example.com", "12.0k"} { + if !strings.Contains(page, want) { + t.Errorf("the dashboard does not show %q", want) + } + } + // A key with no owner predates accounts. Calling it "unknown" would imply + // something is wrong; it is an operator-issued key doing its job. + if !strings.Contains(page, "operator-issued keys") { + t.Error("an unowned key is not explained") + } +} + +func TestTheDashboardShowsWhatIsActuallyRunning(t *testing.T) { + h := Dashboard(fakeSource{}, fakeUsage{}, "v1", time.Second, true, nil) + _, page := get(t, h, "http://127.0.0.1:9090/", nil) + for _, want := range []string{"What is being run", "qwen-coder", "240 ms"} { + if !strings.Contains(page, want) { + t.Errorf("the dashboard does not show %q", want) + } + } +} + +func TestAReportingFailureDoesNotTakeThePageWithIt(t *testing.T) { + // The cluster state is what someone is most likely here for, and a usage + // query is the part most likely to be slow or broken. + h := Dashboard(fakeSource{}, brokenUsage{}, "v1", time.Second, true, nil) + code, page := get(t, h, "http://127.0.0.1:9090/", nil) + if code != http.StatusOK { + t.Fatalf("status %d; a reporting failure took the page down", code) + } + if !strings.Contains(page, "llmgw-node01") { + t.Error("the cluster state was lost along with the usage query") + } +} + +// brokenUsage fails every query, as a database under pressure would. +type brokenUsage struct{} + +func (brokenUsage) UsageSince(context.Context, time.Time) ([]store.KeyUsage, error) { + return nil, errUsage +} + +func (brokenUsage) UsageBuckets(context.Context, time.Time, time.Duration) ([]store.Bucket, error) { + return nil, errUsage +} + +func (brokenUsage) UsageByUser(context.Context, time.Time) ([]store.UserUsage, error) { + return nil, errUsage +} + +func (brokenUsage) UsageByModel(context.Context, time.Time) ([]store.ModelUsage, error) { + return nil, errUsage +} + +var errUsage = errors.New("the database is unhappy")