Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
282 lines
8.9 KiB
Go
282 lines
8.9 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/promq"
|
|
)
|
|
|
|
// vizColours is the categorical palette, in the order it is handed out.
|
|
//
|
|
// Fixed order, never cycled. A fifth line does not get a generated hue — it is
|
|
// folded into "other" by foldSeries below, because a chart whose colours repeat
|
|
// is one where two lines mean the same thing and neither can be read.
|
|
var vizColours = []string{"--viz-1", "--viz-2", "--viz-3", "--viz-4"}
|
|
|
|
// otherColour is the muted grey the tail is drawn in, so it reads as a total
|
|
// rather than as a fifth thing with a name.
|
|
const otherColour = "--muted"
|
|
|
|
const (
|
|
tsW = 720.0
|
|
tsH = 150.0
|
|
tsPadL = 44.0
|
|
tsPadB = 18.0
|
|
tsPadT = 8.0
|
|
tsGridRows = 3
|
|
)
|
|
|
|
// TimeSeries is a chart of several lines over one clock.
|
|
type TimeSeries struct {
|
|
SVG template.HTML
|
|
Legend []LegendEntry
|
|
// Empty is true when there was nothing to draw, so the page can say why
|
|
// rather than showing a frame with no lines in it.
|
|
Empty bool
|
|
Peak string
|
|
}
|
|
|
|
// LegendEntry names one line and the colour it was drawn in.
|
|
//
|
|
// A legend is always present for two lines or more: colour alone is not an
|
|
// encoding anybody can rely on, and a reader who cannot tell two lines apart
|
|
// has a picture rather than a chart.
|
|
type LegendEntry struct {
|
|
Name string
|
|
Colour string
|
|
Last string
|
|
}
|
|
|
|
// LineChart draws several series against one axis.
|
|
//
|
|
// One axis, always. Two measures of different scale belong in two charts: a
|
|
// second y-axis lets any two lines be made to cross wherever the author likes,
|
|
// and the reader has no way to know it happened.
|
|
//
|
|
// Ranked by peak, which is what a gauge asks: the model that hit the ceiling is
|
|
// the one worth a colour. For a counter, see LineChartTotals.
|
|
func LineChart(series []promq.Series, unit string, maxSeries int) TimeSeries {
|
|
return english.LineChart(series, unit, maxSeries)
|
|
}
|
|
|
|
// LineChart is the chart with its axis, peak and legend figures written the
|
|
// way this language writes numbers.
|
|
func (l *Lang) LineChart(series []promq.Series, unit string, maxSeries int) TimeSeries {
|
|
return l.lineChart(series, unit, maxSeries, promq.Series.Max, promq.Series.Last)
|
|
}
|
|
|
|
// LineChartTotals is LineChart ranked by the sum over the window.
|
|
//
|
|
// Which is the right question of a counter. "Who used the most" is answered by
|
|
// the total, and ranking by peak would give a colour to the team that made one
|
|
// large burst over the team that ran steadily all week — the opposite of what a
|
|
// usage report is read for.
|
|
//
|
|
// Its legend gives each line's total too. The latest slice, which is what a
|
|
// gauge's legend shows, is usually a quiet one for a counter: a legend reading
|
|
// "0 · 0 · 0" under a table of hundreds said nothing true.
|
|
func LineChartTotals(series []promq.Series, unit string, maxSeries int) TimeSeries {
|
|
return english.LineChartTotals(series, unit, maxSeries)
|
|
}
|
|
|
|
// LineChartTotals is LineChartTotals in this language.
|
|
func (l *Lang) LineChartTotals(series []promq.Series, unit string, maxSeries int) TimeSeries {
|
|
return l.lineChart(series, unit, maxSeries, promq.Series.Sum, promq.Series.Sum)
|
|
}
|
|
|
|
func (l *Lang) lineChart(series []promq.Series, unit string, maxSeries int,
|
|
weight, legend func(promq.Series) float64,
|
|
) TimeSeries {
|
|
if maxSeries <= 0 || maxSeries > len(vizColours) {
|
|
maxSeries = len(vizColours)
|
|
}
|
|
series = foldSeries(series, maxSeries, weight)
|
|
if len(series) == 0 {
|
|
return TimeSeries{Empty: true}
|
|
}
|
|
|
|
minT, maxT, maxV := bounds(series)
|
|
if maxT.Equal(minT) {
|
|
// One instant is not a line. Reported as empty rather than drawn as a
|
|
// dot in the corner, which reads as a broken chart.
|
|
return TimeSeries{Empty: true}
|
|
}
|
|
// A flat zero is a real answer — nothing is happening — and drawing it
|
|
// along the bottom says so, where an autoscaled empty frame would not.
|
|
if maxV <= 0 {
|
|
maxV = 1
|
|
}
|
|
maxV = niceCeiling(maxV)
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, `<svg class="chart chart-lines" viewBox="0 0 %.0f %.0f" role="img">`, tsW, tsH)
|
|
l.writeGrid(&b, maxV, unit)
|
|
|
|
out := TimeSeries{Peak: l.formatValue(maxV, unit)}
|
|
span := maxT.Sub(minT).Seconds()
|
|
for i, s := range series {
|
|
colour, name := otherColour, l.T("chart.other")
|
|
if s.Name != otherLabel {
|
|
colour, name = vizColours[i%len(vizColours)], s.Name
|
|
}
|
|
writeLine(&b, s, minT, span, maxV, colour)
|
|
out.Legend = append(out.Legend, LegendEntry{
|
|
Name: name, Colour: colour, Last: l.formatValue(legend(s), unit),
|
|
})
|
|
}
|
|
b.WriteString(`</svg>`)
|
|
out.SVG = template.HTML(b.String()) //nolint:gosec // numbers and CSS vars only
|
|
return out
|
|
}
|
|
|
|
const otherLabel = "other"
|
|
|
|
// foldSeries keeps the busiest lines and sums the rest into one.
|
|
//
|
|
// Rather than dropping the tail, which would make a chart that does not add up,
|
|
// or colouring it, which needs hues the palette does not have. The fold is
|
|
// labelled so nobody reads it as a single model.
|
|
//
|
|
// What "busiest" means is the caller's: a gauge is ranked by its peak and a
|
|
// counter by its total, and the two disagree exactly where it matters.
|
|
func foldSeries(in []promq.Series, keep int, weight func(promq.Series) float64) []promq.Series {
|
|
clean := make([]promq.Series, 0, len(in))
|
|
for _, s := range in {
|
|
if len(s.Points) > 0 {
|
|
clean = append(clean, s)
|
|
}
|
|
}
|
|
if len(clean) <= keep {
|
|
return clean
|
|
}
|
|
sort.SliceStable(clean, func(i, j int) bool { return weight(clean[i]) > weight(clean[j]) })
|
|
|
|
rest := clean[keep-1:]
|
|
folded := promq.Series{Name: otherLabel}
|
|
// Summed by timestamp, because the tail's lines do not necessarily share
|
|
// sample times and adding them by index would shift points sideways.
|
|
at := map[int64]float64{}
|
|
for _, s := range rest {
|
|
for _, p := range s.Points {
|
|
at[p.At.Unix()] += p.Value
|
|
}
|
|
}
|
|
keys := make([]int64, 0, len(at))
|
|
for k := range at {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
|
|
for _, k := range keys {
|
|
folded.Points = append(folded.Points, promq.Point{At: time.Unix(k, 0), Value: at[k]})
|
|
}
|
|
return append(append([]promq.Series{}, clean[:keep-1]...), folded)
|
|
}
|
|
|
|
func bounds(series []promq.Series) (minT, maxT time.Time, maxV float64) {
|
|
for _, s := range series {
|
|
for _, p := range s.Points {
|
|
if minT.IsZero() || p.At.Before(minT) {
|
|
minT = p.At
|
|
}
|
|
if p.At.After(maxT) {
|
|
maxT = p.At
|
|
}
|
|
if p.Value > maxV {
|
|
maxV = p.Value
|
|
}
|
|
}
|
|
}
|
|
return minT, maxT, maxV
|
|
}
|
|
|
|
// writeGrid draws the horizontal rules and their labels.
|
|
//
|
|
// Recessive on purpose: the grid is there to be read off, not looked at, and a
|
|
// grid as dark as the data competes with it.
|
|
func (l *Lang) writeGrid(b *strings.Builder, maxV float64, unit string) {
|
|
plotH := tsH - tsPadB - tsPadT
|
|
for i := 0; i <= tsGridRows; i++ {
|
|
y := tsPadT + plotH*float64(i)/tsGridRows
|
|
v := maxV * float64(tsGridRows-i) / tsGridRows
|
|
fmt.Fprintf(b, `<line x1="%.0f" y1="%.1f" x2="%.0f" y2="%.1f" class="axis"/>`,
|
|
tsPadL, y, tsW, y)
|
|
fmt.Fprintf(b, `<text x="%.0f" y="%.1f" class="tick">%s</text>`,
|
|
tsPadL-6, y+3, escapeText(l.formatValue(v, unit)))
|
|
}
|
|
}
|
|
|
|
func writeLine(b *strings.Builder, s promq.Series, minT time.Time,
|
|
span, maxV float64, colour string,
|
|
) {
|
|
plotW := tsW - tsPadL
|
|
plotH := tsH - tsPadB - tsPadT
|
|
|
|
fmt.Fprintf(b, `<polyline class="line" fill="none" stroke="var(%s)" points="`, cssVar(colour))
|
|
for _, p := range s.Points {
|
|
x := tsPadL + plotW*(p.At.Sub(minT).Seconds()/span)
|
|
y := tsPadT + plotH*(1-clamp01(p.Value/maxV))
|
|
fmt.Fprintf(b, "%.1f,%.1f ", x, y)
|
|
}
|
|
b.WriteString(`"/>`)
|
|
}
|
|
|
|
func clamp01(v float64) float64 {
|
|
if math.IsNaN(v) || v < 0 {
|
|
return 0
|
|
}
|
|
if v > 1 {
|
|
return 1
|
|
}
|
|
return v
|
|
}
|
|
|
|
// niceCeiling rounds an axis maximum up to something a person would choose.
|
|
//
|
|
// An axis topping out at 0.8347 makes every gridline a number nobody can hold
|
|
// in their head, and the shape of the data is what the chart is for.
|
|
func niceCeiling(v float64) float64 {
|
|
if v <= 0 {
|
|
return 1
|
|
}
|
|
mag := math.Pow(10, math.Floor(math.Log10(v)))
|
|
for _, step := range []float64{1, 2, 2.5, 5, 10} {
|
|
if v <= step*mag {
|
|
return step * mag
|
|
}
|
|
}
|
|
return 10 * mag
|
|
}
|
|
|
|
func formatValue(v float64, unit string) string { return english.formatValue(v, unit) }
|
|
|
|
// formatValue is a figure on an axis or in a legend, in this language's
|
|
// separators. The percent sign stays after the number, as it is on every
|
|
// other page of the portal, so a figure reads the same wherever it is.
|
|
func (l *Lang) formatValue(v float64, unit string) string {
|
|
switch unit {
|
|
case "%":
|
|
// A decimal below ten, where whole percents would print a quiet
|
|
// machine's axis as 0%, 0%, 0%.
|
|
if v != 0 && v*100 < 10 {
|
|
return l.Num(v*100, 1) + "%"
|
|
}
|
|
return l.Num(v*100, 0) + "%"
|
|
case "s":
|
|
if v < 1 {
|
|
return l.T("chart.axis_ms", l.Num(v*1000, 0))
|
|
}
|
|
return l.T("chart.axis_s", l.Num(v, 1))
|
|
case "bytes":
|
|
// Binary, like every other size on these pages. compact's own
|
|
// suffixes are for counts, and on bytes read as "20.0BB".
|
|
return strings.Replace(l.Bytes(int64(v)), " ", "", 1)
|
|
default:
|
|
return l.Compact(v)
|
|
}
|
|
}
|