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()
}
}
}