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")