Files
llmgw/internal/web/chart.go
T
Ilker ManapandClaude Opus 5 eb9e9b348c 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>
2026-09-17 18:38:21 +02:00

132 lines
3.9 KiB
Go

package web
import (
"fmt"
"html/template"
"strings"
"time"
)
// Inline SVG, generated here.
//
// A chart is a shape, and a column of numbers makes the reader compute the
// shape themselves. Drawing it costs about a hundred lines and no dependency:
// a charting library would mean a JavaScript toolchain in CI, a build step, and
// a megabyte of bundle for what is fundamentally a list of rectangles.
//
// The output is marked template.HTML, so everything interpolated into it must
// be a number this file produced. No caller-supplied string is ever written
// into the markup except through escapeText.
// Series is one set of bars.
type Series struct {
Values []float64
Labels []string
// Colour is a CSS custom property name, so the chart follows the page's
// light and dark palettes instead of pinning its own.
Colour string
}
const (
chartW = 720.0
chartH = 120.0
chartPad = 18.0
)
// BarChart renders a series as bars with a baseline and a peak label.
//
// Empty and all-zero series render as an empty frame rather than nothing: a
// missing chart reads as a broken page, and a flat one reads as no traffic,
// which is the truth.
func BarChart(s Series, unit string) template.HTML {
if s.Colour == "" {
s.Colour = "--accent"
}
var b strings.Builder
fmt.Fprintf(&b, `<svg class="chart" viewBox="0 0 %.0f %.0f" preserveAspectRatio="none" role="img">`,
chartW, chartH)
maxV := 0.0
for _, v := range s.Values {
if v > maxV {
maxV = v
}
}
// Baseline first so bars sit on it.
fmt.Fprintf(&b, `<line x1="0" y1="%.1f" x2="%.0f" y2="%.1f" class="axis"/>`,
chartH-chartPad, chartW, chartH-chartPad)
if n := len(s.Values); n > 0 && maxV > 0 {
slot := chartW / float64(n)
width := slot * 0.72
for i, v := range s.Values {
h := (v / maxV) * (chartH - chartPad*2)
if h < 1 && v > 0 {
// A bar for a real value must be visible, or an hour with
// traffic looks identical to one without.
h = 1
}
x := float64(i)*slot + (slot-width)/2
y := chartH - chartPad - h
title := ""
if i < len(s.Labels) {
title = fmt.Sprintf("<title>%s</title>", escapeText(s.Labels[i]))
}
fmt.Fprintf(&b,
`<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" style="fill:var(%s)">%s</rect>`,
x, y, width, h, cssVar(s.Colour), title)
}
}
b.WriteString(`</svg>`)
peak := fmt.Sprintf("peak %s %s", compact(maxV), unit)
if maxV == 0 {
peak = "nothing recorded"
}
return template.HTML(`<div class="chart-wrap">` + b.String() + //nolint:gosec // built above
`<span class="chart-peak">` + string(escapeText(peak)) + `</span></div>`)
}
// cssVar restricts what can reach a style attribute to names this package
// defines, so a colour can never become a way to inject CSS.
func cssVar(name string) string {
switch name {
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"
}
}
// escapeText is the only path by which non-numeric content enters the markup.
func escapeText(s string) template.HTML {
return template.HTML(template.HTMLEscapeString(s)) //nolint:gosec // escaped on the line above
}
// compact renders a number the way a person reads one at a glance.
func compact(v float64) string {
switch {
case v >= 1e9:
return fmt.Sprintf("%.1fB", v/1e9)
case v >= 1e6:
return fmt.Sprintf("%.1fM", v/1e6)
case v >= 1e3:
return fmt.Sprintf("%.1fk", v/1e3)
case v == float64(int64(v)):
return fmt.Sprintf("%d", int64(v))
default:
return fmt.Sprintf("%.1f", v)
}
}
// hourLabel is what a bar's tooltip says.
func hourLabel(t time.Time, v float64, unit string) string {
return fmt.Sprintf("%s — %s %s", t.Local().Format("Mon 15:04"), compact(v), unit)
}