Show who is using the cluster and what they are running

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) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5 committed 2026-09-17 18:38:21 +02:00
1 parent 28967edab3
commit eb9e9b348c
9 files changed
+667 -1

No files matched your search

+106
View File
@@ -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()
}
+7 -1
View File
@@ -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"
+154
View File
@@ -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(`<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 {
maxV = r.Value
}
}
if maxV <= 0 {
maxV = 1
}
plotW := width - labelW - valueW
var b strings.Builder
fmt.Fprintf(&b, `<div class="chart"><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 {
// 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, `<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)
if 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))
}
b.WriteString(`</svg></div>`)
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, `<svg viewBox="0 0 %.0f %.0f" width="%.0f" height="%.0f" `+
`role="img" aria-label="recent trend">`, w, h, w, h)
b.WriteString(`<polyline fill="none" stroke="var(` + cssVar(colour) + `)` +
`" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" points="`)
step := w / float64(len(values)-1)
for i, v := range values {
fmt.Fprintf(&b, "%.1f,%.1f ", float64(i)*step, h-(v/maxV*(h-4))-2)
}
b.WriteString(`"/></svg>`)
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
}
+117
View File
@@ -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, "<path"); n != 2 {
t.Errorf("drew %d marks for two rows", n)
}
}
func TestAnEmptyChartSaysSoRatherThanRenderingNothing(t *testing.T) {
// A missing chart reads as a broken page.
out := string(HBarChart(nil, "--viz-1", ""))
if !strings.Contains(out, "Nothing recorded") {
t.Errorf("an empty series rendered as %q", out)
}
}
func TestChartTextUsesTextColoursNotTheSeriesColour(t *testing.T) {
// Values and labels wear text tokens; the mark beside them carries the
// 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")
}
}
func TestEveryBarCarriesItsOwnHoverTitle(t *testing.T) {
// The chart is in a browser, so it should answer a pointer. A <title> 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, "<title>"); 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")
}
}
+111
View File
@@ -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.
+46
View File
@@ -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; }
+39
View File
@@ -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>
+6
View File
@@ -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)
},
+81
View File
@@ -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")