Files
llmgw/internal/web/chart.go
T
Ilker ManapandClaude Opus 5 07809490e3
CI / test (push) Has been cancelled
Show usage over time, per organization, team, project and person
Usage could be read as a total — per key, per user, per model, per group —
and the cluster's own charts covered the fleet's hardware. What nobody could
see was when a group used it. "Research doubled its traffic three weeks ago"
is not a question a column of totals can be asked, and it is the question
somebody asks before a quota conversation.

/admin/usage draws requests, tokens and spend over a chosen window, grouped
by any level of the tenancy tree or by person. Person is not a level of that
tree — one person holds keys in several projects — so the grouping is its
own type rather than store.Scope with a fourth value nothing else could use.

Three things learned from the charts already here. Buckets are gap-filled
per group, because a line drawn straight across an absence claims traffic
that did not happen, and because the chart sums its tail by timestamp and
can only do that if every series shares a clock. Series are ranked by total
rather than by peak — a counter's question is "who used the most", and
ranking by peak would colour the team that made one burst over the team that
ran steadily all week. And the spend chart is replaced by a sentence when no
model has a price, rather than drawn flat along the bottom.

The bucket query groups by ordinal and was planned against the cluster's
PostgreSQL before being believed, with a test alongside the others that run
there: three reports have shipped working on SQLite and failing on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:11:31 +02:00

139 lines
4.3 KiB
Go

package web
import (
"fmt"
"html/template"
"strconv"
"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))
case v > 0 && v < 1:
// Two significant digits rather than one decimal place. A spend of
// 0.03 printed as "0.0" says nothing was spent, which is the one thing
// a figure must not say when something was. Counts never reach this
// branch: a whole number is caught above, and zero is whole.
return strconv.FormatFloat(v, 'g', 2, 64)
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)
}