Skip to content
quarry Artifact [4a2be6afb7]
Login

Artifact [4a2be6afb7]

Artifact 4a2be6afb7c2a9afc9b2c1e33664e605107ed32d7c69cbdab194a4909899fad6:


package web

import (
	"bytes"
	_ "embed"
	"encoding/json"
	"fmt"
	"html/template"
	"math"
	"net/http"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/obs"
	"craftdesign.group/quarry/internal/ops"
)

// The traffic page: who reads this forge, how, and what it costs.
//
// Drawn by the server from the request log (obs.Hit, read back by
// obsduck). Every control is a link — the window, the metric the chart
// draws, the filter chips, every row of every card — so the page works
// as HTML; traffic.js only adds what HTML cannot do, naming the point
// under the pointer and turning the globe.

//go:embed assets/traffic.js
var trafficJS []byte

//go:embed assets/geo.js
var geoJS []byte

//go:embed assets/world-110m.json
var worldJSON []byte

var (
	trafficOut = compactJS("traffic.js", trafficJS)
	trafficURL = hashedAsset("traffic", trafficOut)
	// geo.js arrives minified from upstream, like datastar, and is served
	// as vendored.
	geoURL   = hashedAsset("geo", geoJS)
	worldURL = strings.TrimSuffix(hashedAsset("world", worldJSON), ".js") + ".json"
)

// kindNames are the reader kinds as the page says them.
var kindNames = map[string]string{
	obs.KindPerson: "people", obs.KindCrawler: "named crawlers",
	obs.KindAutomated: "other automated", obs.KindUnsure: "unsure",
}

// fleetTrafficView is the page, ready for the template.
type fleetTrafficView struct {
	Off, Empty bool
	// Live is whether the page streams: the short windows only, where a
	// half minute moves the numbers. A month does not need re-reading
	// every thirty seconds.
	Live   bool
	Range  string
	Ranges []trLink
	Chips  []trChip
	Strip  []trMetric
	Metric string
	Chart  trChart
	Kinds  []trLink // the focus picker
	Views  []trLink // one in focus, each on its own

	Globe     template.HTMLAttr
	Countries []trBar

	Downloads []trDownload
	How       []trBar
	Sources   []trSource

	KindMix   []trSeg
	KindBars  []trBar
	Crawlers  []trBar
	Networks  []trBar
	Paths     []trBar
	Served    []trBar
	StatusMix []trSeg
	Status    []trBar
	Hist      []trSeg
	HistEdges []string
	P50, P95  string
	P99       string
	Slowest   []trLat
	ByKind    []trLat
	TimeMix   []trSeg
	TimeBars  []trBar
	Refused   []trBar
	Referrers []trBar

	TrafficURL, GeoURL, WorldURL string
}

type trLink struct {
	Label, Href string
	On          bool
}

type trChip struct{ Name, Value, Remove string }

type trMetric struct {
	Key, Label, Value, Delta, Tone, Href string
	On                                   bool
	Spark                                template.HTML
}

// trBar is one row of a card: what it is, how much, a bar sized against
// the card's largest row, and the link that filters the page to it.
type trBar struct {
	Label, Note, Value, Share, Href string
	Pct                             int
	Flag, Mono                      bool
	Tone                            string // a kind or a status class, for the bar's colour
}

type trSeg struct {
	Pct   float64
	Tone  string
	Label string
}

type trLat struct {
	Label, Href, P50, P95 string
	P50Pct, P95Pct        float64
	Mono                  bool
}

type trDownload struct {
	Repo, Href, Note               string
	Clones, Syncs, Archives, Bytes string
	Clients                        string
	Mix                            []trSeg
}

type trSource struct {
	Name, Kind, KindLabel, Took, N, Why string
}

// trChart is the big chart: its plots (one, or one per kind), the y
// axis, the time labels under it and the labels at its right edge.
type trChart struct {
	Plots []trPlot
	Ticks []trTick
	Each  bool
}

type trPlot struct {
	Name   string
	SVG    template.HTML
	YTicks []trTick
	Labels []trTick // at the right edge, positioned from the top
	Hover  template.HTMLAttr
}

type trTick struct {
	Label, Sub string
	Pos        float64 // percent: from the left for time, from the top otherwise
	Tone       string
}

// trafficParams is the page's state, as the query string carries it.
type trafficParams struct {
	rng, metric, focus, view string
	filters                  map[string]string
}

func parseTrafficParams(q url.Values) trafficParams {
	p := trafficParams{rng: obs.RangeNamed(q.Get("range")).Name, metric: q.Get("metric"),
		focus: q.Get("focus"), view: q.Get("view"), filters: map[string]string{}}
	switch p.metric {
	case "visitors", "requests", "bytes", "server", "auto", "errors":
	default:
		p.metric = "requests"
	}
	if _, ok := kindNames[p.focus]; !ok {
		p.focus = obs.KindPerson
	}
	if p.view != "each" {
		p.view = ""
	}
	for _, f := range obs.TrafficFilters {
		if v := strings.TrimSpace(q.Get(f)); v != "" && len(v) <= 300 {
			p.filters[f] = v
		}
	}
	return p
}

// href is this page with some of its state changed. An empty value
// removes the parameter.
func (p trafficParams) href(set ...string) string {
	v := url.Values{}
	if p.rng != "24h" {
		v.Set("range", p.rng)
	}
	if p.metric != "requests" {
		v.Set("metric", p.metric)
	}
	if p.focus != obs.KindPerson {
		v.Set("focus", p.focus)
	}
	if p.view != "" {
		v.Set("view", p.view)
	}
	for k, x := range p.filters {
		v.Set(k, x)
	}
	for i := 0; i+1 < len(set); i += 2 {
		if set[i+1] == "" {
			v.Del(set[i])
		} else {
			v.Set(set[i], set[i+1])
		}
	}
	// Defaults are left out so the plain page has the plain address.
	if v.Get("range") == "24h" {
		v.Del("range")
	}
	if v.Get("metric") == "requests" {
		v.Del("metric")
	}
	if v.Get("focus") == obs.KindPerson {
		v.Del("focus")
	}
	if len(v) == 0 {
		return "/fleet/traffic"
	}
	return "/fleet/traffic?" + v.Encode()
}

// filter is the link that adds a filter chip.
func (p trafficParams) filter(name, value string) string {
	if value == "" {
		return ""
	}
	return p.href(name, value)
}

func buildTrafficView(t *obs.Traffic, p trafficParams) fleetTrafficView {
	v := fleetTrafficView{Range: p.rng, Metric: p.metric,
		TrafficURL: trafficURL, GeoURL: geoURL, WorldURL: worldURL}
	for _, r := range obs.TrafficRanges {
		v.Ranges = append(v.Ranges, trLink{r.Name, p.href("range", r.Name), r.Name == p.rng})
	}
	for _, f := range obs.TrafficFilters {
		if x, ok := p.filters[f]; ok {
			label := x
			if f == "network" {
				label = "AS" + x
			}
			if f == "kind" {
				label = kindNames[x]
			}
			if f == "country" {
				if c, ok := countries[x]; ok {
					label = c.name
				}
			}
			v.Chips = append(v.Chips, trChip{f, label, p.href(f, "")})
		}
	}
	if t == nil {
		v.Off = true
		return v
	}
	v.Live = p.rng == "1h" || p.rng == "24h"
	v.Empty = t.Totals.Requests == 0 && t.Prev.Requests == 0
	v.Strip = trafficStrip(t, p)
	v.Chart = trafficChart(t, p)
	for _, k := range obs.TrafficKinds {
		v.Kinds = append(v.Kinds, trLink{kindNames[k], p.href("focus", k, "view", ""), p.view == "" && p.focus == k})
	}
	v.Views = []trLink{{"one in focus", p.href("view", ""), p.view == ""}, {"each on its own", p.href("view", "each"), p.view == "each"}}

	// Countries: the list, and the globe's numbers keyed by the
	// outlines' numeric codes.
	globe := map[string]any{}
	v.Countries = bars(t.Countries, func(c obs.Count) trBar {
		name := c.Label
		if cc, ok := countries[c.Label]; ok {
			name = cc.name
			globe[strconv.Itoa(cc.num)] = map[string]any{"n": c.N, "name": cc.name,
				"label": thousands(int(c.N)) + " requests", "href": p.filter("country", c.Label)}
		}
		return trBar{Label: name, Href: p.filter("country", c.Label)}
	}, 10)
	if b, err := json.Marshal(globe); err == nil {
		v.Globe = template.HTMLAttr(`data-countries="` + template.HTMLEscapeString(string(b)) + `"`)
	}

	v.KindBars = bars(t.Kinds, func(c obs.Count) trBar {
		return trBar{Label: kindNames[c.Label], Href: p.filter("kind", c.Label), Tone: c.Label}
	}, 4)
	v.KindMix = mixOf(t.Kinds)
	v.Crawlers = bars(t.Crawlers, func(c obs.Count) trBar {
		b := trBar{Label: c.Label, Note: c.Note, Href: p.filter("crawler", c.Key), Flag: c.Flag}
		if c.Flag {
			b.Tone = "bad"
		}
		return b
	}, 12)
	v.Networks = bars(t.Networks, func(c obs.Count) trBar {
		return trBar{Label: c.Label, Note: c.Note, Href: p.filter("network", c.Key)}
	}, 12)
	v.Paths = bars(t.Paths, func(c obs.Count) trBar {
		return trBar{Label: c.Label, Href: p.filter("path", c.Key), Mono: true}
	}, 40)
	v.Served = barsOf(t.Served, func(c obs.Count) trBar { return trBar{Label: c.Label} }, func(n int64) string { return bytesLabel(n) }, 8)
	v.Status = bars(t.Status, func(c obs.Count) trBar { return trBar{Label: c.Label, Tone: statusTone(c.Key)} }, 8)
	v.StatusMix = statusMix(t.Status)
	v.Refused = bars(t.Refused, func(c obs.Count) trBar {
		return trBar{Label: c.Label, Note: c.Note, Href: p.filter("door", c.Key)}
	}, 8)
	v.Referrers = bars(t.Referrers, func(c obs.Count) trBar { return trBar{Label: c.Label} }, 15)
	v.How = bars(t.How, func(c obs.Count) trBar { return trBar{Label: c.Label} }, 10)

	v.Hist, v.HistEdges = histogramOf(t.Histogram)
	v.P50, v.P95, v.P99 = msLabel(t.P50), msLabel(t.P95), msLabel(t.P99)
	v.Slowest = latencyRows(t.Slowest, func(l obs.Latency) (string, string, bool) {
		return l.Label, p.filter("path", l.Label), true
	})
	v.ByKind = latencyRows(t.ByKind, func(l obs.Latency) (string, string, bool) {
		return kindNames[l.Label], p.filter("kind", l.Label), false
	})
	v.TimeMix, v.TimeBars = timeSplit(t)
	v.Downloads = downloadRows(t.Downloads, p)
	for _, s := range t.Sources {
		v.Sources = append(v.Sources, trSource{Name: s.Name, Kind: s.Kind, KindLabel: kindNames[s.Kind],
			Took: s.Took, N: thousands(int(s.N)), Why: s.Why})
	}
	return v
}

func bars(cs []obs.Count, f func(obs.Count) trBar, limit int) []trBar {
	return barsOf(cs, f, func(n int64) string { return thousands(int(n)) }, limit)
}

// barsOf sizes each row against the largest and states its share of
// the whole.
func barsOf(cs []obs.Count, f func(obs.Count) trBar, value func(int64) string, limit int) []trBar {
	var top, sum int64
	for _, c := range cs {
		top = max(top, c.N)
		sum += c.N
	}
	var out []trBar
	for i, c := range cs {
		if i == limit {
			break
		}
		b := f(c)
		b.Value = value(c.N)
		if top > 0 {
			b.Pct = int(math.Round(float64(c.N) / float64(top) * 100))
		}
		if sum > 0 {
			b.Share = strconv.Itoa(int(math.Round(float64(c.N)/float64(sum)*100))) + "%"
		}
		out = append(out, b)
	}
	return out
}

func mixOf(kinds []obs.Count) []trSeg {
	var sum int64
	by := map[string]int64{}
	for _, c := range kinds {
		sum += c.N
		by[c.Label] = c.N
	}
	var out []trSeg
	for _, k := range obs.TrafficKinds {
		if sum > 0 && by[k] > 0 {
			out = append(out, trSeg{Pct: float64(by[k]) / float64(sum) * 100, Tone: k, Label: kindNames[k]})
		}
	}
	return out
}

func statusTone(code string) string {
	switch {
	case strings.HasPrefix(code, "2"):
		return "ok"
	case strings.HasPrefix(code, "3"):
		return "unsure"
	case strings.HasPrefix(code, "5"):
		return "bad"
	}
	return "warn"
}

func statusMix(st []obs.Count) []trSeg {
	var sum int64
	for _, c := range st {
		sum += c.N
	}
	var out []trSeg
	for _, c := range st {
		if sum > 0 {
			out = append(out, trSeg{Pct: float64(c.N) / float64(sum) * 100, Tone: statusTone(c.Key), Label: c.Label})
		}
	}
	return out
}

func histogramOf(h []int64) ([]trSeg, []string) {
	var top int64
	for _, n := range h {
		top = max(top, n)
	}
	var out []trSeg
	for i, n := range h {
		label := "over " + msLabel(obs.HistEdgesMs[len(obs.HistEdgesMs)-1])
		if i < len(obs.HistEdgesMs) {
			label = "under " + msLabel(obs.HistEdgesMs[i])
		}
		pct := 0.0
		if top > 0 {
			pct = float64(n) / float64(top) * 100
		}
		out = append(out, trSeg{Pct: pct, Label: label + ": " + thousands(int(n))})
	}
	return out, []string{"0", "50ms", "200ms", "1s", "5s+"}
}

func latencyRows(ls []obs.Latency, label func(obs.Latency) (string, string, bool)) []trLat {
	var top int64
	for _, l := range ls {
		top = max(top, l.P95Ms)
	}
	var out []trLat
	for _, l := range ls {
		name, href, mono := label(l)
		r := trLat{Label: name, Href: href, Mono: mono, P50: msLabel(l.P50Ms), P95: msLabel(l.P95Ms)}
		if top > 0 {
			r.P50Pct = float64(l.P50Ms) / float64(top) * 100
			r.P95Pct = float64(l.P95Ms) / float64(top) * 100
		}
		out = append(out, r)
	}
	return out
}

func timeSplit(t *obs.Traffic) ([]trSeg, []trBar) {
	parts := []obs.Count{
		{Label: "reading stores", N: t.StoreMs, Key: obs.KindPerson},
		{Label: "the forge's own work", N: t.LogicMs, Key: obs.KindCrawler},
		{Label: "rendering and sending", N: t.RenderMs, Key: obs.KindAutomated},
	}
	sum := t.StoreMs + t.LogicMs + t.RenderMs
	if sum == 0 {
		return nil, nil
	}
	var segs []trSeg
	for _, c := range parts {
		segs = append(segs, trSeg{Pct: float64(c.N) / float64(sum) * 100, Tone: c.Key, Label: c.Label})
	}
	return segs, barsOf(parts, func(c obs.Count) trBar { return trBar{Label: c.Label, Tone: c.Key} },
		func(n int64) string { return strconv.Itoa(int(math.Round(float64(n)/float64(sum)*100))) + "%" }, 3)
}

func downloadRows(ds []obs.Download, p trafficParams) []trDownload {
	var out []trDownload
	for _, d := range ds {
		r := trDownload{Repo: d.Repo, Href: p.filter("repo", d.Repo),
			Clones: thousands(int(d.Clones)), Syncs: thousands(int(d.Fetches)),
			Archives: thousands(int(d.Archive)), Bytes: bytesLabel(d.Bytes)}
		if d.SignedIn > 0 {
			r.Note = fmt.Sprintf("%d signed in", d.SignedIn)
		}
		var sum int64
		for _, n := range d.Mix {
			sum += n
		}
		for i, n := range d.Mix {
			if sum > 0 && n > 0 {
				r.Mix = append(r.Mix, trSeg{Pct: float64(n) / float64(sum) * 100, Tone: obs.TrafficKinds[i], Label: kindNames[obs.TrafficKinds[i]]})
			}
		}
		r.Clients = clientSplit(d)
		out = append(out, r)
	}
	return out
}

// clientSplit says which clients read a repository, largest first, as
// shares when there are several and plainly when there is one.
func clientSplit(d obs.Download) string {
	parts := []struct {
		name string
		n    int64
	}{{"zit", d.Zit}, {"fossil", d.Fossil}, {"other", d.Other}}
	sort.SliceStable(parts, func(i, j int) bool { return parts[i].n > parts[j].n })
	sum := d.Zit + d.Fossil + d.Other
	var out []string
	for _, x := range parts {
		if x.n == 0 || sum == 0 {
			continue
		}
		out = append(out, fmt.Sprintf("%s %d%%", x.name, int(math.Round(float64(x.n)/float64(sum)*100))))
	}
	return strings.Join(out, " · ")
}

// trafficStrip is the summary strip: each number, its change against
// the window before, a sparkline, and the link that charts it.
func trafficStrip(t *obs.Traffic, p trafficParams) []trMetric {
	series := func(f func(obs.TrafficPoint) float64) []float64 {
		out := make([]float64, len(t.Points))
		for i, x := range t.Points {
			out[i] = f(x)
		}
		return out
	}
	reqs := func(x obs.TrafficPoint) float64 {
		return float64(x.ByKind[0] + x.ByKind[1] + x.ByKind[2] + x.ByKind[3])
	}
	auto := func(x obs.TrafficPoint) float64 {
		if n := reqs(x); n > 0 {
			return float64(x.ByKind[1]+x.ByKind[2]) / n * 100
		}
		return 0
	}
	type m struct {
		key, label string
		now, prev  float64
		show       string
		// worse is whether a rise is bad news, which colours its change.
		worse bool
		pts   bool
		f     func(obs.TrafficPoint) float64
	}
	ms := []m{
		{"visitors", "Unique visitors", float64(t.Totals.Visitors), float64(t.Prev.Visitors), thousands(int(t.Totals.Visitors)), false, false,
			func(x obs.TrafficPoint) float64 { return float64(x.Visitors) }},
		{"requests", "Requests", float64(t.Totals.Requests), float64(t.Prev.Requests), shortCount(t.Totals.Requests), true, false, reqs},
		{"bytes", "Data served", float64(t.Totals.Bytes), float64(t.Prev.Bytes), bytesLabel(t.Totals.Bytes), true, false,
			func(x obs.TrafficPoint) float64 { return float64(x.Bytes) }},
		{"server", "Server time", float64(t.Totals.ServerMs), float64(t.Prev.ServerMs), durLabel(t.Totals.ServerMs), true, false,
			func(x obs.TrafficPoint) float64 { return float64(x.ServerMs) }},
		{"auto", "Automated", t.Totals.Automated * 100, t.Prev.Automated * 100, strconv.Itoa(int(math.Round(t.Totals.Automated*100))) + "%", true, true, auto},
		{"errors", "Errors (5xx)", float64(t.Totals.Errors), float64(t.Prev.Errors), thousands(int(t.Totals.Errors)), true, false,
			func(x obs.TrafficPoint) float64 { return float64(x.Errors) }},
	}
	var out []trMetric
	for _, x := range ms {
		d, tone := change(x.now, x.prev, x.pts)
		if tone != "" {
			if (tone == "up") == x.worse {
				tone = "worse"
			} else {
				tone = "better"
			}
		}
		out = append(out, trMetric{Key: x.key, Label: x.label, Value: x.show, Delta: d, Tone: tone,
			Href: p.href("metric", x.key), On: x.key == p.metric, Spark: sparkline(series(x.f))})
	}
	return out
}

// change is a number's move against the window before, "" when there
// is nothing to compare with.
func change(now, prev float64, pts bool) (string, string) {
	if pts {
		d := math.Round(now - prev)
		if d == 0 {
			return "no change", ""
		}
		return fmt.Sprintf("%+.0f pts", d), map[bool]string{true: "up", false: "down"}[d > 0]
	}
	if prev == 0 {
		return "", ""
	}
	pct := math.Round((now - prev) / prev * 100)
	if pct == 0 {
		return "no change", ""
	}
	return fmt.Sprintf("%+.0f%%", pct), map[bool]string{true: "up", false: "down"}[pct > 0]
}

const plotW, plotH = 1000.0, 200.0

// sparkline is a summary cell's line.
func sparkline(vals []float64) template.HTML {
	if len(vals) < 2 {
		return ""
	}
	top := 0.0
	for _, v := range vals {
		top = math.Max(top, v)
	}
	return template.HTML(`<svg class="tr-spark" viewBox="0 0 100 26" preserveAspectRatio="none" aria-hidden="true"><path d="` +
		linePath(vals, top, 100, 24, 2) + `"/></svg>`)
}

// linePath is the polyline of vals across w, scaled so top touches the
// top edge (with pad left above it).
func linePath(vals []float64, top, w, h, pad float64) string {
	var b strings.Builder
	for i, v := range vals {
		x := float64(i) / float64(len(vals)-1) * w
		y := h + pad
		if top > 0 {
			y = pad + (1-v/top)*h
		}
		if i == 0 {
			fmt.Fprintf(&b, "M%.1f %.1f", x, y)
		} else {
			fmt.Fprintf(&b, "L%.1f %.1f", x, y)
		}
	}
	return b.String()
}

// areaPath is linePath closed down to the baseline, optionally down to
// another line instead (the band between two stacked series).
func areaPath(upper, lower []float64, top float64) string {
	line := linePath(upper, top, plotW, plotH, 0)
	var b strings.Builder
	b.WriteString(line)
	for i := len(upper) - 1; i >= 0; i-- {
		x := float64(i) / float64(len(upper)-1) * plotW
		y := plotH
		if lower != nil && top > 0 {
			y = (1 - lower[i]/top) * plotH
		}
		fmt.Fprintf(&b, "L%.1f %.1f", x, y)
	}
	b.WriteString("Z")
	return b.String()
}

// niceTop rounds a maximum up to a figure an axis can be labelled with.
func niceTop(v float64) float64 {
	if v <= 0 {
		return 1
	}
	e := math.Pow(10, math.Floor(math.Log10(v)))
	for _, m := range []float64{1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10} {
		if m*e >= v {
			return m * e
		}
	}
	return 10 * e
}

func trafficChart(t *obs.Traffic, p trafficParams) trChart {
	n := len(t.Points)
	ch := trChart{}
	if n < 2 {
		return ch
	}
	timeFmt := map[string]string{"1h": "15:04", "24h": "15:04", "7d": "Mon 15:04", "30d": "Mon Jan 2 15:04"}[p.rng]
	tickFmt := map[string]string{"1h": "15:04", "24h": "15:04", "7d": "Mon", "30d": "Jan 2"}[p.rng]
	at := make([]string, n)
	for i, x := range t.Points {
		at[i] = x.At.UTC().Format(timeFmt)
	}
	for i := 0; i < 5; i++ {
		j := int(math.Round(float64(i) / 4 * float64(n-1)))
		ch.Ticks = append(ch.Ticks, trTick{Label: t.Points[j].At.UTC().Format(tickFmt), Pos: float64(j) / float64(n-1) * 100})
	}
	val, fmtv := metricOf(p.metric)
	cur := make([]float64, n)
	prev := make([]float64, n)
	for i := range t.Points {
		cur[i] = val(t.Points[i])
		if i < len(t.PrevPoints) {
			prev[i] = val(t.PrevPoints[i])
		}
	}
	before := "the " + p.rng + " before"
	yticks := func(top float64) []trTick {
		return []trTick{{Label: fmtv(top), Pos: 0}, {Label: fmtv(top / 2), Pos: 50}, {Label: fmtv(0), Pos: 100}}
	}
	ypos := func(v, top float64) float64 { return (1 - v/top) * 100 }
	hover := func(rows []map[string]any) template.HTMLAttr {
		b, _ := json.Marshal(map[string]any{"at": at, "rows": rows})
		return template.HTMLAttr(`data-hover="` + template.HTMLEscapeString(string(b)) + `"`)
	}
	strs := func(vs []float64) []string {
		out := make([]string, len(vs))
		for i, v := range vs {
			out[i] = fmtv(v)
		}
		return out
	}
	if p.metric == "requests" && p.view == "each" {
		ch.Each = true
		for j, k := range obs.TrafficKinds {
			s := make([]float64, n)
			ps := make([]float64, n)
			var sum float64
			for i := range t.Points {
				s[i] = float64(t.Points[i].ByKind[j])
				sum += s[i]
				if i < len(t.PrevPoints) {
					ps[i] = float64(t.PrevPoints[i].ByKind[j])
				}
			}
			top := niceTop(math.Max(maxFloat(s), maxFloat(ps)))
			svg := `<path class="tr-area" d="` + areaPath(s, nil, top) + `"/>` +
				`<path class="tr-prev" d="` + linePath(ps, top, plotW, plotH, 0) + `"/>` +
				`<path class="tr-line" d="` + linePath(s, top, plotW, plotH, 0) + `"/>`
			ch.Plots = append(ch.Plots, trPlot{Name: kindNames[k], SVG: plotSVG(svg, kindNames[k]),
				YTicks: []trTick{{Label: fmtv(top), Pos: 0}, {Label: "0", Pos: 100}},
				Labels: []trTick{{Label: fmtv(sum), Sub: "peak " + fmtv(maxFloat(s)), Pos: ypos(s[n-1], top)}},
				Hover:  hover([]map[string]any{{"name": kindNames[k], "v": strs(s)}, {"name": before, "v": strs(ps)}})})
		}
		return ch
	}
	top := niceTop(math.Max(maxFloat(cur), maxFloat(prev)))
	var svg string
	var labels []trTick
	var rows []map[string]any
	if p.metric == "requests" {
		fi := 0
		for i, k := range obs.TrafficKinds {
			if k == p.focus {
				fi = i
			}
		}
		focus := make([]float64, n)
		var fsum, tsum float64
		for i := range t.Points {
			focus[i] = float64(t.Points[i].ByKind[fi])
			fsum += focus[i]
			tsum += cur[i]
		}
		svg = `<path class="tr-rest" d="` + areaPath(cur, focus, top) + `"/>` +
			`<path class="tr-restline" d="` + linePath(cur, top, plotW, plotH, 0) + `"/>` +
			`<path class="tr-area" d="` + areaPath(focus, nil, top) + `"/>` +
			`<path class="tr-line" d="` + linePath(focus, top, plotW, plotH, 0) + `"/>` +
			`<path class="tr-prev" d="` + linePath(prev, top, plotW, plotH, 0) + `"/>`
		yf, yt := ypos(focus[n-1], top), ypos(cur[n-1], top)
		// The two labels must not sit on each other.
		if yf-yt < 18 {
			yt = math.Max(0, yf-18)
		}
		labels = []trTick{{Label: kindNames[p.focus], Sub: fmtv(fsum), Pos: yf, Tone: "focus"},
			{Label: "all readers", Sub: fmtv(tsum), Pos: yt}}
		rest := make([]float64, n)
		for i := range rest {
			rest[i] = cur[i] - focus[i]
		}
		rows = []map[string]any{{"name": kindNames[p.focus], "v": strs(focus)}, {"name": "everyone else", "v": strs(rest)},
			{"name": "all readers", "v": strs(cur)}, {"name": before, "v": strs(prev)}}
	} else {
		svg = `<path class="tr-area" d="` + areaPath(cur, nil, top) + `"/>` +
			`<path class="tr-line" d="` + linePath(cur, top, plotW, plotH, 0) + `"/>` +
			`<path class="tr-prev" d="` + linePath(prev, top, plotW, plotH, 0) + `"/>`
		labels = []trTick{{Label: "now", Sub: fmtv(cur[n-1]), Pos: ypos(cur[n-1], top), Tone: "focus"}}
		rows = []map[string]any{{"name": "this " + p.rng, "v": strs(cur)}, {"name": before, "v": strs(prev)}}
	}
	ch.Plots = []trPlot{{SVG: plotSVG(svg, ""), YTicks: yticks(top), Labels: labels, Hover: hover(rows)}}
	return ch
}

func plotSVG(paths, label string) template.HTML {
	return template.HTML(`<svg class="tr-svg" viewBox="0 0 1000 200" preserveAspectRatio="none" role="img" aria-label="` +
		template.HTMLEscapeString(label) + `">` + paths + `</svg>`)
}

func maxFloat(vs []float64) float64 {
	m := 0.0
	for _, v := range vs {
		m = math.Max(m, v)
	}
	return m
}

// metricOf is how the chart reads a point for the chosen metric, and
// how it says the number.
func metricOf(metric string) (func(obs.TrafficPoint) float64, func(float64) string) {
	count := func(v float64) string { return shortCount(int64(math.Round(v))) }
	switch metric {
	case "visitors":
		return func(x obs.TrafficPoint) float64 { return float64(x.Visitors) }, count
	case "bytes":
		return func(x obs.TrafficPoint) float64 { return float64(x.Bytes) }, func(v float64) string { return bytesLabel(int64(v)) }
	case "server":
		return func(x obs.TrafficPoint) float64 { return float64(x.ServerMs) }, func(v float64) string { return durLabel(int64(v)) }
	case "auto":
		return func(x obs.TrafficPoint) float64 {
				n := x.ByKind[0] + x.ByKind[1] + x.ByKind[2] + x.ByKind[3]
				if n == 0 {
					return 0
				}
				return float64(x.ByKind[1]+x.ByKind[2]) / float64(n) * 100
			}, func(v float64) string {
				return strconv.Itoa(int(math.Round(v))) + "%"
			}
	case "errors":
		return func(x obs.TrafficPoint) float64 { return float64(x.Errors) }, count
	}
	return func(x obs.TrafficPoint) float64 {
		return float64(x.ByKind[0] + x.ByKind[1] + x.ByKind[2] + x.ByKind[3])
	}, count
}

func shortCount(n int64) string {
	switch {
	case n >= 1_000_000:
		return strconv.FormatFloat(float64(n)/1e6, 'f', 1, 64) + "M"
	case n >= 10_000:
		return strconv.FormatInt(int64(math.Round(float64(n)/1e3)), 10) + "k"
	}
	return thousands(int(n))
}

func bytesLabel(n int64) string {
	switch {
	case n >= 1e9:
		return strconv.FormatFloat(float64(n)/1e9, 'f', 1, 64) + " GB"
	case n >= 1e6:
		return strconv.FormatInt(int64(math.Round(float64(n)/1e6)), 10) + " MB"
	case n >= 1e3:
		return strconv.FormatInt(int64(math.Round(float64(n)/1e3)), 10) + " kB"
	}
	return strconv.FormatInt(n, 10) + " B"
}

func durLabel(ms int64) string {
	d := time.Duration(ms) * time.Millisecond
	switch {
	case d >= time.Hour:
		return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
	case d >= time.Minute:
		return fmt.Sprintf("%dm", int(d.Minutes()))
	}
	return fmt.Sprintf("%ds", int(d.Seconds()))
}

func msLabel(ms int64) string {
	if ms >= 1000 {
		return strconv.FormatFloat(float64(ms)/1000, 'f', 1, 64) + "s"
	}
	return strconv.FormatInt(ms, 10) + "ms"
}

// trafficEvery is how often the live page re-reads the log: the cadence
// rows reach the store at (FlushEvery), so reading faster would only
// redraw the same numbers.
const trafficEvery = 30 * time.Second

// liveTraffic streams the traffic page's body, patched when it changed.
func liveTraffic(r *ops.Registry, w http.ResponseWriter, req *http.Request) {
	p := parseTrafficParams(req.URL.Query())
	if p.rng != "1h" && p.rng != "24h" {
		http.Error(w, "the long windows are not live", http.StatusBadRequest)
		return
	}
	w, fl, ok := liveStream(w)
	if !ok {
		http.Error(w, "streaming unsupported", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("X-Accel-Buffering", "no")
	fmt.Fprint(w, ": live\n\n")
	fl.Flush()
	tick := time.NewTicker(trafficEvery)
	defer tick.Stop()
	heartbeat := time.NewTicker(20 * time.Second)
	defer heartbeat.Stop()
	last := ""
	for {
		select {
		case <-req.Context().Done():
			return
		case <-heartbeat.C:
			fmt.Fprint(w, ": ping\n\n")
			fl.Flush()
		case <-tick.C:
			// Asked again on every tick, as the ops stream does: a revoked
			// token or a dropped flag ends the stream.
			if tok, byToken := ops.TokenOf(req.Context()); byToken && !tok.SiteAdmin() {
				return
			}
			t, err := obs.Default.Traffic(obs.TrafficQuery{Range: obs.RangeNamed(p.rng), Filters: p.filters})
			if err != nil {
				continue
			}
			var buf bytes.Buffer
			if err := hooksOf(req.Context()).template(fleetTrafficPage).ExecuteTemplate(&buf, "tr-body", buildTrafficView(t, p)); err != nil {
				continue
			}
			frag := buf.String()
			if frag == last {
				continue
			}
			last = frag
			fmt.Fprint(w, "event: datastar-patch-elements\n")
			for _, line := range strings.Split(frag, "\n") {
				fmt.Fprintf(w, "data: elements %s\n", line)
			}
			fmt.Fprint(w, "\n")
			fl.Flush()
		}
	}
}