diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index 240d450..30b5620 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 8c4ba3a..592c051 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 0000000..48d42a7
--- /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, ``)
+ 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, ``)
+ 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 0000000..42efd46
--- /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, "")]
+ 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, " 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 8df9831..5198705 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 9ec393b..6203832 100644
--- a/internal/web/templates/dashboard.html
+++ b/internal/web/templates/dashboard.html
@@ -20,6 +20,15 @@
+{{if .HasCharts}}
+
+
{{.Requests24h}}
requests · 24h
{{.RequestSpark}}
+
{{compact .Tokens24h}}
tokens · 24h
{{.TokenSpark}}
+
{{.Errors24h}}
errors · 24h
+
{{units .Spend24h}}
spend · 24h
+
+{{end}}
+
Capacity
{{if .Summary.GPUs}}
Class
In use
Free
Faulty
Usage
@@ -57,6 +66,36 @@ for a model that needs this one.
{{end}}
Counted by the model that actually answered, which differs from the one
+requested whenever a fallback served it.
+{{end}}
+
Nodes
{{if .Nodes}}
Node
State
GPUs
RAM
Classes
Fabrics
Models
Replicas
Last seen
diff --git a/internal/web/web.go b/internal/web/web.go
index 4b63bec..da68b81 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 3394536..e2135b3 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")