main.go

// Command telemetry is a live request explorer over a rolling window of 100,000
// spans — the example for data at a scale that breaks naive UI code.
//
// A background goroutine serves a synthetic fleet at ~1,400 requests a second
// into a fixed-size ring; the dashboard snapshots that window several times a
// second, re-filters and re-sorts all 100,000 rows, and redraws stat tiles, three
// live charts, and a virtualized table from the same single pass. Filter by
// service, route, host, or trace prefix; sort on any column; watch the Age
// column tick every frame. Nothing is precomputed and nothing is sampled.
//
// Three things it is built to show:
//
//   - Virtualization. widget.LazyList mounts only the rows on screen, so the
//     table costs the same at a hundred rows and a hundred thousand — and a
//     column recomputed from the clock on every frame costs nothing.
//
//   - Concurrency. The producer is an ordinary goroutine. The only coordination
//     is a mutex held for the length of one copy, so the UI never waits on it.
//
//   - Honest numbers. The dashboard shows how long its own rebuild took, so the
//     claim is on screen rather than in this comment.
//
//     go run ./examples/telemetry
package main

import (
	"flag"
	"log"
	"os"
	"path/filepath"
	"time"

	"golang.org/x/image/font/gofont/gobold"
	"golang.org/x/image/font/gofont/gomono"
	"golang.org/x/image/font/gofont/goregular"

	"github.com/doug/gophics/app"
	"github.com/doug/gophics/geom"
	"github.com/doug/gophics/theme"
)

func main() {
	otlp := flag.String("otlp", "", "load an OpenTelemetry OTLP/JSON trace export instead of the synthetic fleet")
	flag.Parse()

	store := NewStore(9)
	if *otlp != "" {
		if err := load(store, *otlp); err != nil {
			log.Fatal(err)
		}
	} else {
		// Open on a full window rather than filling one over the minute-plus it
		// would take in real time: the charts have a minute of history at once.
		store.Fill(Window, time.Duration(Window)*time.Second/Rate)
		stop := make(chan struct{})
		defer close(stop)
		go store.Produce(stop)
	}

	if err := app.Run(App{Store: store}, app.Config{
		Title: "Fleet",
		AppID: "com.gophics.telemetry",
		Size:  geom.Size{W: 1360, H: 860},
		Font:  goregular.TTF,
		FontFamilies: map[string][]byte{
			theme.FontBold: gobold.TTF,
			"mono":         gomono.TTF,
		},
	}); err != nil {
		log.Fatal(err)
	}
}

// load reads a capture off disk. Anything an OTLP/JSON exporter writes works;
// see otlp.go for what is read out of it.
func load(store *Store, path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()
	v := newVocab()
	spans, err := DecodeOTLP(f, v)
	if err != nil {
		return err
	}
	useVocab(v)
	store.Replace(spans, filepath.Base(path))
	log.Printf("loaded %d spans from %s", len(spans), path)
	return nil
}

dict.go

package main

import "sync"

// dict interns names so a Span can refer to a service, route, or host by a
// two-byte index instead of carrying a string. That is what keeps a span at 36
// bytes and, more importantly, what lets the text filter be resolved against a
// few dozen names once per query instead of against a hundred thousand rows.
//
// Dictionaries are built during setup — the synthetic fleet registers its whole
// vocabulary up front, and an OTLP load interns as it decodes — and are only
// read afterwards. The producer goroutine never interns, so the UI can read
// names without a lock. Add returns an error rather than growing past the index
// width so that invariant can't be broken quietly by a pathological input.
type dict struct {
	mu    sync.Mutex
	names []string
	idx   map[string]int
}

const maxDictEntries = 1 << 16

// intern returns name's index, adding it if new. Names past the index width
// collapse onto the last entry rather than corrupting the mapping; a capture
// with 65,536 distinct routes is a broken capture, not a use case.
func (d *dict) intern(name string) uint16 {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.idx == nil {
		d.idx = make(map[string]int)
	}
	if i, ok := d.idx[name]; ok {
		return uint16(i)
	}
	if len(d.names) >= maxDictEntries {
		return uint16(len(d.names) - 1)
	}
	d.idx[name] = len(d.names)
	d.names = append(d.names, name)
	return uint16(len(d.names) - 1)
}

// Name returns the i-th name, or "?" if the index is out of range.
func (d *dict) Name(i uint16) string {
	if int(i) >= len(d.names) {
		return "?"
	}
	return d.names[i]
}

// Names is the whole vocabulary, in insertion order.
func (d *dict) Names() []string { return d.names }

// Len is how many distinct names have been interned.
func (d *dict) Len() int { return len(d.names) }

// vocab is the set of dictionaries one dataset is described by. A span's
// indices are only meaningful against the vocabulary it was created with, so
// loading a capture builds a fresh one and swaps it in wholesale — decoding into
// the live dictionaries instead would leave the previous dataset's services
// listed in the filter and its routes matchable, and a decode that failed
// halfway would leave a vocabulary that describes neither.
type vocab struct{ svc, route, host *dict }

func newVocab() *vocab { return &vocab{svc: &dict{}, route: &dict{}, host: &dict{}} }

// The vocabulary the UI and the query engine read. It is replaced only from the
// UI goroutine, and only while the producer is stopped (see Store.Replace).
var (
	svcDict   *dict
	routeDict *dict
	hostDict  *dict
)

// useVocab installs v as the active vocabulary.
func useVocab(v *vocab) { svcDict, routeDict, hostDict = v.svc, v.route, v.host }

func init() { useVocab(newVocab()) }

otlp.go

package main

import (
	_ "embed"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"strconv"
	"strings"
)

// OTLP/JSON — the OpenTelemetry Protocol's JSON encoding — is what this loads.
// It is what an `otlphttp` exporter posts with Content-Type: application/json,
// what the Collector's file exporter writes, and what `telemetrygen` emits, so
// a real capture from a real service can be dropped straight into this demo.
//
// The shape is three levels deep: resourceSpans (one per producing process,
// carrying the resource attributes that identify it), each holding scopeSpans
// (one per instrumentation library), each holding the spans themselves.
//
// Two wrinkles the spec imposes and this decoder handles:
//
//   - 64-bit integers are encoded as JSON *strings*, because JSON numbers can't
//     carry them losslessly. Timestamps and int attributes therefore arrive
//     quoted, and sometimes unquoted from encoders that ignore that rule, so
//     every number here accepts both.
//   - The HTTP semantic conventions were renamed in v1.21 (http.method →
//     http.request.method, http.status_code → http.response.status_code, and so
//     on). Captures in the wild use either, so both spellings are read.
//
// Files may hold one JSON object or a stream of them (the file exporter writes
// one per line); the decoder loops until EOF, so both work.

// sampleOTLP is a small, readable capture in the real wire format — 83 spans
// over five services, one of which is having a bad afternoon. It ships with the
// example so the OTLP path can be demonstrated with no file and no collector,
// including in the browser.
//
//go:embed otlp-sample.json
var sampleOTLP string

// DecodeOTLP reads OTLP/JSON traces and converts them to Spans, interning names
// into v as it goes. Timestamps are returned in milliseconds on the file's own
// clock; Store.Replace rebases them.
func DecodeOTLP(r io.Reader, v *vocab) ([]Span, error) {
	dec := json.NewDecoder(r)
	var out []Span
	for {
		var req otlpRequest
		if err := dec.Decode(&req); err != nil {
			if errors.Is(err, io.EOF) {
				break
			}
			return nil, fmt.Errorf("otlp: %w", err)
		}
		for _, rs := range req.ResourceSpans {
			out = append(out, rs.spans(v)...)
		}
	}
	if len(out) == 0 {
		return nil, errors.New("otlp: no spans found (is this an OTLP/JSON trace export?)")
	}
	return out, nil
}

type otlpRequest struct {
	ResourceSpans []resourceSpans `json:"resourceSpans"`
}

type resourceSpans struct {
	Resource struct {
		Attributes []attr `json:"attributes"`
	} `json:"resource"`
	ScopeSpans []struct {
		Spans []otlpSpan `json:"spans"`
	} `json:"scopeSpans"`
}

// spans flattens one resource's spans, applying the resource-level identity
// (service name, host) to each.
func (rs resourceSpans) spans(v *vocab) []Span {
	res := attrs(rs.Resource.Attributes)
	service := res.first("service.name")
	if service == "" {
		service = "unknown"
	}
	resHost := res.first("host.name", "service.instance.id", "k8s.pod.name")

	svc := v.svc.intern(service)
	var out []Span
	for _, ss := range rs.ScopeSpans {
		for _, sp := range ss.Spans {
			out = append(out, sp.decode(v, svc, resHost))
		}
	}
	return out
}

type otlpSpan struct {
	TraceID    string      `json:"traceId"`
	Name       string      `json:"name"`
	StartNanos json.Number `json:"startTimeUnixNano"`
	EndNanos   json.Number `json:"endTimeUnixNano"`
	Attributes []attr      `json:"attributes"`
	Status     struct {
		Code json.Number `json:"code"`
	} `json:"status"`
}

func (sp otlpSpan) decode(v *vocab, svc uint16, resHost string) Span {
	a := attrs(sp.Attributes)

	// Route: the low-cardinality template if the instrumentation recorded one,
	// else the raw path, else the span name — which for HTTP servers is
	// conventionally "GET /route" already.
	route := a.first("http.route", "url.path", "http.target")
	if method := a.first("http.request.method", "http.method"); method != "" && route != "" {
		route = method + " " + route
	}
	if route == "" {
		route = sp.Name
	}
	host := a.first("server.address", "net.host.name", "host.name")
	if host == "" {
		host = resHost
	}
	if host == "" {
		host = "—"
	}

	start, end := u64(sp.StartNanos), u64(sp.EndNanos)
	dur := int32(0)
	if end > start {
		dur = int32((end - start) / 1000) // nanoseconds → microseconds
	}

	out := Span{
		At:    int32(start / 1e6), // nanoseconds → milliseconds
		Dur:   dur,
		Bytes: -1,
		Svc:   svc,
		Route: v.route.intern(route),
		Host:  v.host.intern(host),
		Code:  status(a, sp.Status.Code),
	}
	if n := a.firstInt("http.response.body.size", "http.response_content_length", "http.response.size"); n >= 0 {
		out.Bytes = int32(n)
	}
	// A short or malformed trace ID decodes to whatever prefix is valid; the
	// rest stays zero rather than dropping the span.
	if raw, err := hex.DecodeString(sp.TraceID); err == nil {
		copy(out.Trace[:], raw)
	}
	return out
}

// status prefers the HTTP status code, and falls back to the span's own OTLP
// status — 2 is STATUS_CODE_ERROR — so non-HTTP spans still colour correctly.
func status(a attrs, spanStatus json.Number) uint16 {
	if n := a.firstInt("http.response.status_code", "http.status_code"); n > 0 {
		return uint16(n)
	}
	if spanStatus.String() == "2" || spanStatus.String() == "STATUS_CODE_ERROR" {
		return 500
	}
	return 200
}

// --- Attributes --------------------------------------------------------------

// attr is one key/value pair. OTLP wraps every value in a type tag, so a string
// arrives as {"stringValue":"GET"} and an int as {"intValue":"200"} — quoted,
// per the 64-bit rule.
type attr struct {
	Key   string `json:"key"`
	Value struct {
		StringValue *string      `json:"stringValue"`
		IntValue    *json.Number `json:"intValue"`
		DoubleValue *json.Number `json:"doubleValue"`
		BoolValue   *bool        `json:"boolValue"`
	} `json:"value"`
}

func (a attr) str() string {
	switch {
	case a.Value.StringValue != nil:
		return *a.Value.StringValue
	case a.Value.IntValue != nil:
		return a.Value.IntValue.String()
	case a.Value.DoubleValue != nil:
		return a.Value.DoubleValue.String()
	case a.Value.BoolValue != nil:
		return strconv.FormatBool(*a.Value.BoolValue)
	}
	return ""
}

type attrs []attr

// first returns the value of the first key present, trying each in turn — which
// is how the old and new semantic-convention spellings are both accepted.
func (as attrs) first(keys ...string) string {
	for _, k := range keys {
		for _, a := range as {
			if a.Key == k {
				if v := strings.TrimSpace(a.str()); v != "" {
					return v
				}
			}
		}
	}
	return ""
}

// firstInt is first, parsed; -1 means absent or unparseable.
func (as attrs) firstInt(keys ...string) int64 {
	if v := as.first(keys...); v != "" {
		if n, err := strconv.ParseInt(v, 10, 64); err == nil {
			return n
		}
	}
	return -1
}

// u64 reads a JSON number that may have arrived quoted or bare.
func u64(n json.Number) uint64 {
	v, err := strconv.ParseUint(strings.Trim(n.String(), `"`), 10, 64)
	if err != nil {
		return 0
	}
	return v
}

query.go

package main

import (
	"bytes"
	"math/bits"
	"slices"
	"strings"
	"time"
)

// This file is the query engine: one pass over the snapshot that filters,
// aggregates, and (when the sort isn't chronological) sorts. It runs a few times
// a second on the UI goroutine, between frames, over the whole 100,000-span
// window — so everything here is written to stay well inside a frame budget.

// Sort columns. They are the table's column indices, so a header tap maps
// straight through.
const (
	colTime = iota
	colAge
	colService
	colRoute
	colHost
	colTrace
	colStatus
	colLatency
	colBytes
	numCols
)

// Status filter classes.
const (
	statusAny = iota
	statusOK
	statusClient
	statusServer
)

// Query is everything the filter bar and the table header contribute.
type Query struct {
	Text    string
	Status  int
	Svc     int // -1 for every service
	SortCol int
	Desc    bool
}

// Buckets for the latency histogram the UI draws, in microseconds. The scale is
// logarithmic because request latency is: a linear histogram of these spans is a
// single bar at the left and eleven empty ones.
var histEdges = [...]int32{1e3, 2e3, 5e3, 1e4, 2e4, 5e4, 1e5, 2e5, 5e5, 1e6, 2e6}

var histLabels = [...]string{"<1ms", "2", "5", "10", "20", "50", "100", "200", "500", "1s", "2s", "2s+"}

// ThroughputSecs is how many seconds of history the throughput chart shows.
const ThroughputSecs = 60

// Result is one query's output: the row order to display, plus every number the
// dashboard shows. They come from the same pass, because a second pass over
// 100,000 rows to compute the charts would cost as much as the first.
type Result struct {
	Rows []Span  // the snapshot the view indexes into
	View []int32 // matching rows, in display order
	Now  int32

	Matching  int
	Client    int // 4xx
	Server    int // 5xx
	P50       int32
	P95       int32
	P99       int32
	Hist      [len(histLabels)]int32
	PerSec    [ThroughputSecs]int32
	SvcP95    []int32 // per service, indexed by dictionary id
	SvcCount  []int32
	Elapsed   time.Duration
	SortedRow bool // whether this rebuild had to sort (see Run)
}

// Run filters, aggregates, and orders the snapshot.
func Run(rows []Span, now int32, q Query) Result {
	start := time.Now()
	res := Result{Rows: rows, Now: now}

	// The text filter is resolved against the dictionaries once, up front, into
	// a bitset per dictionary. After this the 100,000-row scan is integer
	// lookups — it never touches a string, which is what keeps a filter over the
	// whole window off the frame budget.
	f := compile(q.Text)

	nsvc := svcDict.Len()
	res.SvcP95 = make([]int32, nsvc)
	res.SvcCount = make([]int32, nsvc)

	view := make([]int32, 0, len(rows))
	var fine [200]int32
	svcFine := make([][200]int32, nsvc)

	for i := range rows {
		sp := rows[i]
		if q.Svc >= 0 && int(sp.Svc) != q.Svc {
			continue
		}
		switch q.Status {
		case statusOK:
			if !sp.OK() {
				continue
			}
		case statusClient:
			if sp.Code < 400 || sp.Code >= 500 {
				continue
			}
		case statusServer:
			if !sp.Failed() {
				continue
			}
		}
		if f.on && !f.match(sp) {
			continue
		}

		view = append(view, int32(i))
		b := latBucket(sp.Dur)
		fine[b]++
		svcFine[sp.Svc][b]++
		res.SvcCount[sp.Svc]++
		switch {
		case sp.Failed():
			res.Server++
		case sp.Code >= 400:
			res.Client++
		}
		if age := now - sp.At; age >= 0 && age < ThroughputSecs*1000 {
			res.PerSec[ThroughputSecs-1-age/1000]++
		}
	}

	res.View = view
	res.Matching = len(view)
	res.P50 = percentile(&fine, res.Matching, 0.50)
	res.P95 = percentile(&fine, res.Matching, 0.95)
	res.P99 = percentile(&fine, res.Matching, 0.99)
	for i := range svcFine {
		res.SvcP95[i] = percentile(&svcFine[i], int(res.SvcCount[i]), 0.95)
	}
	for b, n := range fine {
		if n > 0 {
			res.Hist[histBucket(bucketUs(b))] += n
		}
	}

	res.SortedRow = sortView(view, rows, q)
	if q.SortCol == colTime || q.SortCol == colAge {
		// Chronological order is the order the ring already holds, so the
		// default view — newest first — needs no sort at all, only a reverse.
		// It is worth the special case: it is the order the table opens in and
		// the one a live tail sits in, so the common path stays free.
		if (q.SortCol == colTime) == q.Desc {
			slices.Reverse(view)
		}
	}

	res.Elapsed = time.Since(start)
	return res
}

// sortView orders the view for any non-chronological column, and reports
// whether it actually had to sort.
func sortView(view []int32, rows []Span, q Query) bool {
	if q.SortCol == colTime || q.SortCol == colAge {
		return false
	}
	cmp := func(a, b int32) int {
		x, y := rows[a], rows[b]
		var d int
		switch q.SortCol {
		case colService:
			d = strings.Compare(svcDict.Name(x.Svc), svcDict.Name(y.Svc))
		case colRoute:
			d = strings.Compare(routeDict.Name(x.Route), routeDict.Name(y.Route))
		case colHost:
			d = strings.Compare(hostDict.Name(x.Host), hostDict.Name(y.Host))
		case colTrace:
			d = bytes.Compare(x.Trace[:], y.Trace[:])
		case colStatus:
			d = int(x.Code) - int(y.Code)
		case colLatency:
			d = int(x.Dur - y.Dur)
		case colBytes:
			d = int(x.Bytes - y.Bytes)
		}
		if d == 0 { // ties break by time, so the order is total and stable
			d = int(x.At - y.At)
		}
		if q.Desc {
			return -d
		}
		return d
	}
	slices.SortFunc(view, cmp)
	return true
}

// filter is a compiled search: one bitset per dictionary plus an optional trace
// prefix. Matching a hex prefix is done on the raw bytes — the query's nibbles
// against the ID's leading ones — so no span is ever formatted to be searched.
type filter struct {
	on    bool
	svc   []bool
	route []bool
	host  []bool

	tracePfx  [16]byte
	traceNibs int // how many leading hex digits of the query to honour
}

// match tests one span. It is the innermost thing in the scan, so it is nothing
// but slice indexing and, at most, a 16-byte compare.
func (f *filter) match(sp Span) bool {
	if f.svc[sp.Svc] || f.route[sp.Route] || f.host[sp.Host] {
		return true
	}
	if f.traceNibs == 0 {
		return false
	}
	whole := f.traceNibs / 2
	if !bytes.Equal(sp.Trace[:whole], f.tracePfx[:whole]) {
		return false
	}
	if f.traceNibs%2 == 1 { // an odd query length ends on a half byte
		return sp.Trace[whole]>>4 == f.tracePfx[whole]>>4
	}
	return true
}

// compile resolves the search text against the dictionaries once, up front.
// After this the 100,000-row scan is integer lookups — it never touches a
// string, which is what keeps a filter over the whole window off the frame
// budget however fast someone types.
func compile(text string) filter {
	f := filter{
		svc:   make([]bool, svcDict.Len()),
		route: make([]bool, routeDict.Len()),
		host:  make([]bool, hostDict.Len()),
	}
	q := strings.ToLower(strings.TrimSpace(text))
	if q == "" {
		return f
	}
	f.on = true
	for i, n := range svcDict.Names() {
		f.svc[i] = strings.Contains(strings.ToLower(n), q)
	}
	for i, n := range routeDict.Names() {
		f.route[i] = strings.Contains(strings.ToLower(n), q)
	}
	for i, n := range hostDict.Names() {
		f.host[i] = strings.Contains(strings.ToLower(n), q)
	}
	if len(q) <= 32 {
		if nibs, ok := parseNibbles(q, f.tracePfx[:]); ok {
			f.traceNibs = nibs
		}
	}
	return f
}

// parseNibbles writes q's hex digits into dst, high nibble first, and reports
// how many it wrote. It fails on any non-hex character, which is how a search
// for "search" is treated as a name and one for "4afb" as a trace prefix.
func parseNibbles(q string, dst []byte) (int, bool) {
	for i := 0; i < len(q); i++ {
		var v byte
		switch c := q[i]; {
		case c >= '0' && c <= '9':
			v = c - '0'
		case c >= 'a' && c <= 'f':
			v = c - 'a' + 10
		default:
			return 0, false
		}
		if i%2 == 0 {
			dst[i/2] = v << 4
		} else {
			dst[i/2] |= v
		}
	}
	return len(q), true
}

// latBucket maps microseconds to one of ~200 log-spaced buckets with integer
// operations only: the bucket's octave is the position of the leading set bit,
// and the three bits under it split that octave into eight — about 9%
// resolution, which is finer than a percentile off a live sample deserves and
// costs a shift and a mask instead of a call to math.Log.
func latBucket(us int32) int {
	u := uint32(us)
	if u < 16 {
		return int(u)
	}
	e := bits.Len32(u) // 5..32
	return 16 + (e-5)*8 + int((u>>(e-4))&7)
}

// bucketUs is latBucket's inverse: the middle of the range a bucket covers.
func bucketUs(b int) int32 {
	if b < 16 {
		return int32(b)
	}
	b -= 16
	shift := b/8 + 1
	sub := uint32(b % 8)
	return int32(((8+sub)<<shift + (9+sub)<<shift) / 2)
}

// percentile reads a quantile straight off the histogram — O(buckets), not
// O(n log n), and no copy of the matching durations to sort.
func percentile(h *[200]int32, n int, p float64) int32 {
	if n == 0 {
		return 0
	}
	want := int32(float64(n) * p)
	var cum int32
	for b, c := range h {
		if cum += c; cum > want {
			return bucketUs(b)
		}
	}
	return bucketUs(len(h) - 1)
}

// histBucket places a duration in one of the twelve display buckets.
func histBucket(us int32) int {
	for i, e := range histEdges {
		if us < e {
			return i
		}
	}
	return len(histEdges)
}

store.go

package main

import (
	"math"
	"math/rand"
	"slices"
	"sync"
	"time"
)

// This file is the data side of the demo: a fixed-size window of request spans
// that a background goroutine keeps filling while the UI reads it. It is the
// example's argument for Go's concurrency story — the producer is an ordinary
// goroutine on an ordinary ticker, the UI never blocks on it, and the only
// coordination is one mutex held for the length of a copy.
//
// Spans come from one of two places, through the same door: the synthetic fleet
// below, or a real OpenTelemetry capture (see otlp.go).

const (
	// Window is how many spans the store keeps. Older ones fall out the back,
	// so memory is bounded no matter how long the demo runs: at 36 bytes a span
	// the whole window is about 3.6 MB, small enough to snapshot wholesale
	// several times a second.
	Window = 100_000

	// Rate is how many spans a second the synthetic fleet "serves".
	Rate = 1400
)

// Span is one served request. The service, route, and host are dictionary
// indices rather than strings (see dict.go), so a filter scan over the whole
// window never touches a string — the difference between a 100,000-row filter
// that is imperceptible and one that is not.
//
// Trace is the full 16-byte OpenTelemetry trace ID, kept whole rather than
// truncated to something table-sized: it is the one field you copy out of a
// viewer and paste into another tool, so a shortened one would be useless.
type Span struct {
	At    int32 // ms since the store's epoch
	Dur   int32 // microseconds
	Bytes int32 // response size; -1 when the capture didn't say
	Trace [16]byte
	Svc   uint16
	Route uint16
	Host  uint16
	Code  uint16 // HTTP status
}

// OK reports whether the request succeeded (2xx/3xx).
func (s Span) OK() bool { return s.Code < 400 }

// Failed reports whether the server itself failed (5xx) — the class that means
// the fleet is broken rather than the caller.
func (s Span) Failed() bool { return s.Code >= 500 }

// TraceHex renders the trace ID the way every tracing UI does.
func (s Span) TraceHex() string {
	const hex = "0123456789abcdef"
	out := make([]byte, 32)
	for i, b := range s.Trace {
		out[i*2], out[i*2+1] = hex[b>>4], hex[b&15]
	}
	return string(out)
}

// The synthetic fleet's vocabulary. It is interned up front so the producer
// goroutine only ever emits indices that already exist.
var (
	genServices = []string{"checkout", "catalog", "search", "identity", "payments", "inventory", "shipping", "reviews"}
	genRoutes   = []string{
		"GET /v1/items", "GET /v1/items/{id}", "POST /v1/cart", "POST /v1/checkout",
		"GET /v1/search", "POST /v1/auth/token", "GET /v1/me", "POST /v1/payments",
		"GET /v1/stock/{sku}", "PUT /v1/stock/{sku}", "GET /v1/shipments/{id}",
		"POST /v1/reviews", "GET /v1/reviews", "DELETE /v1/cart/{id}",
	}
	genRegions = []string{"iad", "ord", "sfo", "fra", "sin"}
)

// baseLatency is each generated route's healthy median, in microseconds.
// Checkout, search, and payments are the slow ones, which is what gives the
// latency histogram a shape instead of a single spike.
var baseLatency = [...]float64{
	1800, 2400, 5200, 42000, 68000, 9500, 2100, 51000,
	3300, 7400, 4100, 6800, 2900, 3600,
}

// Store is the rolling window. It is a ring: writes overwrite the oldest span,
// so the buffer is allocated once at startup and never grows.
type Store struct {
	mu    sync.Mutex
	buf   []Span
	n     uint64 // total spans ever written
	epoch time.Time

	// Source names where the data came from, for the header line.
	source string
	// paused stops the synthetic producer. Loading a capture sets it: a window
	// that mixed a fixed capture with a live generator would be neither, and
	// the generator's span indices refer to a vocabulary the load replaced.
	paused bool

	rng      *rand.Rand
	svcIDs   []uint16
	routeIDs []uint16
	hostIDs  []uint16
	degraded int     // the service currently having a bad time
	degrade  float64 // 0..1, how bad
}

func NewStore(seed int64) *Store {
	s := &Store{
		buf:      make([]Span, Window),
		epoch:    time.Now(),
		source:   "synthetic fleet",
		rng:      rand.New(rand.NewSource(seed)),
		degraded: 3,
		// Open mid-incident: a dashboard whose charts are all flat says nothing
		// about whether it would show you anything if they weren't.
		degrade: 0.5,
	}
	v := newVocab()
	s.registerVocabulary(v)
	useVocab(v)
	return s
}

// registerVocabulary interns every name the generator can emit, so that
// producing a span is pure arithmetic and the dictionaries are immutable while
// the producer runs.
func (s *Store) registerVocabulary(v *vocab) {
	for _, n := range genServices {
		s.svcIDs = append(s.svcIDs, v.svc.intern(n))
	}
	for _, n := range genRoutes {
		s.routeIDs = append(s.routeIDs, v.route.intern(n))
	}
	for _, r := range genRegions {
		for i := range 8 {
			s.hostIDs = append(s.hostIDs, v.host.intern(r+"-"+string(rune('a'+i))+string(rune('0'+i%4))))
		}
	}
}

// Source describes where the spans came from.
func (s *Store) Source() string { return s.source }

// Len is how many spans the window currently holds.
func (s *Store) Len() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.len()
}

func (s *Store) len() int {
	if s.n < Window {
		return int(s.n)
	}
	return Window
}

// Snapshot copies the window, oldest first, into dst (reusing its capacity) and
// returns it along with the store's clock reading at the moment of the copy.
//
// Copying looks wasteful next to reading the ring in place under a read lock,
// and it is the point: the UI gets a slice nobody else will touch, so it can
// filter, sort, and re-read it across many frames while the producer keeps
// writing. The lock is held for one memmove of a few megabytes — tens of
// microseconds — instead of for the milliseconds a sort would take.
func (s *Store) Snapshot(dst []Span) ([]Span, int32) {
	s.mu.Lock()
	defer s.mu.Unlock()
	n := s.len()
	if cap(dst) < n {
		dst = make([]Span, n)
	}
	dst = dst[:n]
	if s.n < Window {
		copy(dst, s.buf[:n])
	} else {
		// The ring's oldest entry is wherever the next write will land.
		head := int(s.n % Window)
		copy(dst, s.buf[head:])
		copy(dst[Window-head:], s.buf[:head])
	}
	return dst, s.now()
}

func (s *Store) now() int32 { return int32(time.Since(s.epoch).Milliseconds()) }

// Now is the store's clock, in the same units as Span.At. The UI reads it every
// frame for the live Age column — which is the cheap half of "live": ages
// advance on every frame without the view being rebuilt at all.
func (s *Store) Now() int32 { return s.now() }

// Total is how many spans have ever been written, including those the ring has
// since evicted. Sampling it over time gives the observed ingest rate.
func (s *Store) Total() uint64 {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.n
}

// Wall converts a span's timestamp to a wall clock time.
func (s *Store) Wall(at int32) time.Time { return s.epoch.Add(time.Duration(at) * time.Millisecond) }

// Replace swaps the window's contents for a decoded capture, keeping the newest
// Window spans and rebasing the clock so the newest span reads as "now" — a
// capture from last Tuesday should still show a sensible Age column.
//
// Spans are sorted by time first. An OTLP file is grouped by service, not
// ordered by clock, and the ring being chronological is not a cosmetic detail:
// it is the invariant the query engine's default view relies on to skip sorting
// entirely (see Run). Loading an unsorted capture without this shows the last
// service in the file as though it were the most recent traffic.
func (s *Store) Replace(spans []Span, source string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	slices.SortFunc(spans, func(a, b Span) int { return int(a.At - b.At) })
	if len(spans) > Window {
		spans = spans[len(spans)-Window:]
	}
	newest := int32(0)
	if len(spans) > 0 {
		newest = spans[len(spans)-1].At
	}
	s.n = 0
	s.epoch = time.Now()
	for _, sp := range spans {
		sp.At -= newest // newest lands at 0, everything else is negative
		s.write(sp)
	}
	s.source = source
	s.paused = true
}

// Fill writes n spans immediately, spread backwards over the given duration —
// the cold start, so the demo opens on a full window and populated charts
// rather than filling in over a minute and a half.
//
// Traffic is warped rather than spread evenly: a slow swell plus a faster
// ripple, so the throughput chart opens with a shape instead of the dead flat
// line a uniform fill produces. The incident drifts through the backfill too,
// which is what puts one service's bar above the others from the first frame.
func (s *Store) Fill(n int, over time.Duration) {
	s.mu.Lock()
	defer s.mu.Unlock()
	span := float64(over.Milliseconds())
	for i := range n {
		if i%180 == 0 {
			s.drift()
		}
		u := float64(i) / float64(n) // 0 = oldest, 1 = now
		// Warping u compresses spans into some stretches of the timeline and
		// thins them out in others, which is what a varying arrival rate looks
		// like after the fact. The amplitudes are kept small enough that dw/du
		// never approaches zero: a warp that briefly ran backwards would pile
		// thousands of spans onto one millisecond and spike the chart.
		w := u + 0.022*math.Sin(u*7.1) + 0.010*math.Sin(u*19.3+1.2)
		s.write(s.gen(int32(-span * (1 - w))))
	}
}

// Produce runs the fleet until stop is closed, writing a batch every tick. It is
// meant to be started with `go`.
func (s *Store) Produce(stop <-chan struct{}) {
	const tick = 25 * time.Millisecond
	t := time.NewTicker(tick)
	defer t.Stop()
	batch := int(Rate * tick.Seconds())
	for {
		select {
		case <-stop:
			return
		case <-t.C:
			s.mu.Lock()
			if s.paused {
				s.mu.Unlock()
				continue
			}
			s.drift()
			now := s.now()
			for range batch {
				s.write(s.gen(now))
			}
			s.mu.Unlock()
		}
	}
}

// write appends one span to the ring. The caller holds the lock.
func (s *Store) write(sp Span) {
	s.buf[s.n%Window] = sp
	s.n++
}

// drift moves the incident around: the degraded service's severity wanders, and
// when it recovers another service is picked. Without it every chart settles
// into the same shape within seconds and the demo has nothing to show.
func (s *Store) drift() {
	s.degrade += (s.rng.Float64() - 0.47) * 0.02
	switch {
	case s.degrade > 1:
		s.degrade = 1
	case s.degrade < 0:
		s.degrade = 0
		s.degraded = s.rng.Intn(len(genServices))
	}
}

// gen synthesizes one plausible span at time `at`. Latency is lognormal around
// the route's median — the distribution real request latency actually follows,
// and the reason the histogram needs log-spaced buckets to read at all.
func (s *Store) gen(at int32) Span {
	route := s.rng.Intn(len(genRoutes))
	svc := route % len(genServices)
	if s.rng.Intn(4) == 0 { // routes aren't perfectly partitioned across services
		svc = s.rng.Intn(len(genServices))
	}

	sick := 0.0
	if svc == s.degraded {
		sick = s.degrade
	}
	// A lognormal draw: exp(normal) has the long right tail latency has.
	dur := baseLatency[route] * math.Exp(s.rng.NormFloat64()*0.62) * (1 + sick*6)
	if s.rng.Float64() < 0.004+sick*0.03 { // the tail: a retry, a cold cache
		dur *= 4 + s.rng.Float64()*8
	}

	code := uint16(200)
	switch r := s.rng.Float64(); {
	case r < 0.004+sick*0.14:
		code = [...]uint16{500, 502, 503}[s.rng.Intn(3)]
	case r < 0.02+sick*0.05:
		code = [...]uint16{400, 401, 404, 429}[s.rng.Intn(4)]
	case r < 0.08:
		code = [...]uint16{201, 204, 304}[s.rng.Intn(3)]
	}

	sp := Span{
		At:    at,
		Dur:   int32(dur),
		Bytes: int32(180 + s.rng.ExpFloat64()*4200),
		Svc:   s.svcIDs[svc],
		Route: s.routeIDs[route],
		Host:  s.hostIDs[s.rng.Intn(len(s.hostIDs))],
		Code:  code,
	}
	// A real trace ID is 16 random bytes; filling it from the same seeded source
	// keeps the whole generator reproducible.
	s.rng.Read(sp.Trace[:])
	return sp
}

ui.go

package main

import (
	"bytes"
	"fmt"
	"io"
	"sort"
	"strings"
	"time"

	"github.com/doug/gophics/chart"
	"github.com/doug/gophics/geom"
	"github.com/doug/gophics/layout"
	"github.com/doug/gophics/paint"
	"github.com/doug/gophics/shell"
	"github.com/doug/gophics/theme"
	"github.com/doug/gophics/widget"
)

// rebuildEvery bounds how often the whole window is re-filtered while data is
// arriving. Every rebuild is a full pass over 100,000 spans, so this is the knob
// that trades freshness against the frame budget: at four a second the scan
// costs a small fraction of one core, and the table still reads as a live tail.
// Filter and sort changes bypass it — those must feel instant.
const rebuildEvery = 250 * time.Millisecond

type App struct{ Store *Store }

func (App) CreateState() widget.State { return &dash{} }

type dash struct {
	widget.StateBase[App]
	ctx   widget.Ctx
	store *Store

	snap []Span // reused across rebuilds so a snapshot allocates nothing
	res  Result
	q    Query

	live     bool
	dirty    bool // the query changed; rebuild on the next tick regardless
	since    time.Duration
	selected int
	loadErr  string

	// Test hooks. lastCols records the column set the last build chose, and
	// cellCount (when non-nil) counts cells built — which is how the
	// virtualization claim is tested rather than asserted.
	lastCols  []int
	cellCount *int

	// Ingest rate, sampled from the store's running total.
	lastTotal uint64
	lastAt    time.Time
	ingest    float64
}

// stateHook, if set, receives the state on mount — for tests to drive input.
var stateHook func(*dash)

func (s *dash) Init(ctx widget.Ctx) {
	s.ctx = ctx
	s.store = s.W().Store
	s.q = Query{Svc: -1, SortCol: colTime, Desc: true}
	s.live = true
	s.selected = -1
	s.lastAt = time.Now()
	s.lastTotal = s.store.Total()
	s.rebuild()
	ctx.AddTicker(s)
	if stateHook != nil {
		stateHook(s)
	}
}

// Tick decides whether this frame needs a new view. It always asks for a
// rebuild, not just a repaint: the Age column is derived from the clock rather
// than from the data, and it is produced in cell() during Build, so a repaint
// alone redraws the same strings.
//
// That distinction was a visible bug. Invalidate asks for a frame; only
// SetState marks the tree for rebuilding. With a repaint alone the ages sat
// still until something else dirtied a row — and hovering does dirty exactly
// one row, so the row under the pointer would advance its age while every row
// around it stayed where it was.
//
// Rebuilding every frame is what the design already assumes: cell() runs only
// for the rows the viewport shows, twenty-odd of them, however many rows the
// table holds.
func (s *dash) Tick(dt float64) bool {
	s.since += time.Duration(dt * float64(time.Second))
	if s.dirty || (s.live && s.since >= rebuildEvery) {
		s.rebuild()
	}
	if now := time.Now(); now.Sub(s.lastAt) >= time.Second {
		total := s.store.Total()
		s.ingest = float64(total-s.lastTotal) / now.Sub(s.lastAt).Seconds()
		s.lastTotal, s.lastAt = total, now
	}
	s.SetState(nil) // rebuild: Age is computed in cell(), not at paint time
	return true
}

func (s *dash) rebuild() {
	snap, now := s.store.Snapshot(s.snap)
	s.snap = snap
	s.res = Run(snap, now, s.q)
	s.since, s.dirty = 0, false
}

// setQuery applies a filter or sort change and forces an immediate rebuild.
func (s *dash) setQuery(f func(*Query)) {
	s.SetState(func() {
		f(&s.q)
		s.dirty = true
		s.selected = -1
	})
}

// --- Build -------------------------------------------------------------------

func (s *dash) Build(ctx widget.Ctx) widget.Widget {
	th := theme.Auto(ctx)
	return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: th.Bg,
		Child: widget.Padding{All: 18, Child: widget.LayoutBuilder{
			Build: func(cs layout.Constraints) widget.Widget {
				narrow := cs.BoundedW() && cs.Max.W < 900
				return widget.Flex{
					Axis:       layout.Vertical,
					CrossAlign: layout.CrossStretch,
					Children: []widget.Widget{
						s.header(th),
						widget.Sized{H: 14},
						s.tiles(th, narrow),
						widget.Sized{H: 14},
						widget.Sized{H: 196, Child: s.charts(th, narrow)},
						widget.Sized{H: 14},
						s.filters(th, narrow),
						widget.Sized{H: 12},
						widget.Expand(theme.Card{Pad: 10, Child: s.table(th, narrow)}),
						s.detail(th),
					},
				}
			},
		}},
	}}
}

func (s *dash) header(th theme.Theme) widget.Widget {
	sub := fmt.Sprintf("%s spans · %s", commas(int64(len(s.res.Rows))), s.store.Source())
	if s.ingest > 0 {
		sub += fmt.Sprintf(" · %s/s arriving", commas(int64(s.ingest+0.5)))
	}
	if s.loadErr != "" {
		sub = s.loadErr
	}
	return widget.Row(
		widget.Expand(widget.Flex{
			Axis:       layout.Vertical,
			CrossAlign: layout.CrossStart,
			Children: []widget.Widget{
				widget.Text{Value: "Fleet", Font: theme.FontBold, Size: th.Type.Title, Color: th.Text},
				widget.Sized{H: 2},
				widget.Text{Value: sub, Size: th.Type.Caption, Color: th.Muted},
			},
		}),
		s.loadButtons(),
		widget.Text{Value: "Live", Size: th.Type.Label, Color: th.Muted},
		widget.Sized{W: 8},
		theme.Switch{On: s.live, Label: "Live tail",
			OnChange: func(v bool) { s.SetState(func() { s.live = v; s.dirty = v }) }},
	)
}

// tiles is the headline row. Every number on it comes out of the same pass that
// built the table's row order, so the dashboard and the grid can never disagree.
func (s *dash) tiles(th theme.Theme, narrow bool) widget.Widget {
	r := s.res
	errRate := 0.0
	if r.Matching > 0 {
		errRate = float64(r.Server) / float64(r.Matching) * 100
	}
	errTone := th.Success
	if errRate > 1 {
		errTone = th.Warning
	}
	if errRate > 4 {
		errTone = th.Danger
	}

	cells := []widget.Widget{
		tile(th, "Matching", commas(int64(r.Matching)), th.Text),
		tile(th, "p50", fmtDur(r.P50), th.Text),
		tile(th, "p95", fmtDur(r.P95), th.Text),
		tile(th, "p99", fmtDur(r.P99), th.Text),
		tile(th, "5xx", fmt.Sprintf("%.2f%%", errRate), errTone),
		tile(th, "Rebuild", fmt.Sprintf("%.1f ms", float64(r.Elapsed.Microseconds())/1000), th.Muted),
	}
	if narrow { // four across reads better than six squeezed
		cells = cells[:4]
	}
	kids := make([]widget.Widget, 0, len(cells)*2)
	for i, c := range cells {
		if i > 0 {
			kids = append(kids, widget.Sized{W: 10})
		}
		kids = append(kids, widget.Expand(c))
	}
	row := widget.Row(kids...)
	row.CrossAlign = layout.CrossStretch
	return row
}

func tile(th theme.Theme, label, value string, tone paint.Color) widget.Widget {
	return theme.Card{Pad: 10, Child: widget.Flex{
		Axis:       layout.Vertical,
		CrossAlign: layout.CrossStart,
		Children: []widget.Widget{
			widget.Text{Value: label, Size: th.Type.Caption, Color: th.Muted},
			widget.Sized{H: 3},
			widget.Text{Value: value, Font: "mono", Size: th.Type.Heading, Color: tone},
		},
	}}
}

func (s *dash) charts(th theme.Theme, narrow bool) widget.Widget {
	if narrow {
		return chartCard(th, "Throughput", s.throughput(th))
	}
	order, capped := s.svcOrder()
	row := widget.Row(
		widget.Flexible{Flex: 3, Child: chartCard(th, "Throughput · spans/s", s.throughput(th))},
		widget.Sized{W: 12},
		widget.Flexible{Flex: 3, Child: chartCard(th, "Latency distribution", s.latency(th))},
		widget.Sized{W: 12},
		widget.Flexible{Flex: 3, Child: chartCard(th, svcTitle(capped), s.byService(th, order))},
	)
	row.CrossAlign = layout.CrossStretch
	return row
}

func chartCard(th theme.Theme, title string, c widget.Widget) widget.Widget {
	return theme.Card{Pad: 12, Child: widget.Flex{
		Axis:       layout.Vertical,
		CrossAlign: layout.CrossStretch,
		Children: []widget.Widget{
			widget.Text{Value: title, Size: th.Type.Label, Color: th.Muted},
			widget.Sized{H: 8},
			widget.Expand(c),
		},
	}}
}

// throughput is the last minute of arrivals, one point a second. It is drawn
// from the filtered set, so narrowing to one service narrows the chart with it.
func (s *dash) throughput(th theme.Theme) widget.Widget {
	data := make([]chart.Datum, 0, ThroughputSecs)
	// The newest second is still filling, so it always reads low; drop it.
	for i := range ThroughputSecs - 1 {
		data = append(data, chart.Datum{X: float64(i - (ThroughputSecs - 1)), Y: float64(s.res.PerSec[i])})
	}
	col := th.ChartAt(0)
	return chart.Chart{
		Marks: []chart.Mark{
			chart.AreaMark{Data: data, Color: col, Alpha: 0.18},
			chart.LineMark{Data: data, Color: col, Width: 2},
		},
		XAxis:      chart.Axis{Ticks: 4, Format: func(v float64) string { return fmt.Sprintf("%.0fs", v) }},
		YAxis:      chart.Axis{Ticks: 3},
		LabelColor: th.Text, AxisColor: th.Muted, GridColor: th.Border,
	}
}

// latency is the log-bucketed histogram. Bars are tinted by how slow the bucket
// is, so the shape of the tail is legible without reading the axis.
func (s *dash) latency(th theme.Theme) widget.Widget {
	pairs := make([]chart.Pair, 0, len(histLabels))
	for i, l := range histLabels {
		pairs = append(pairs, chart.Pair{Label: l, Value: float64(s.res.Hist[i])})
	}
	data := chart.Pairs(pairs)
	for i := range data {
		switch {
		case i >= len(histLabels)-2:
			data[i].Color = th.Danger
		case i >= len(histLabels)-4:
			data[i].Color = th.Warning
		default:
			data[i].Color = th.ChartAt(1)
		}
	}
	return chart.Chart{
		Marks:      []chart.Mark{chart.BarMark{Data: data}},
		XAxis:      chart.Axis{Ticks: len(histLabels)},
		YAxis:      chart.Axis{Ticks: 3},
		LabelColor: th.Text, AxisColor: th.Muted, GridColor: th.Border,
	}
}

// svcOrder picks which services the p95 chart shows: the busiest, in dictionary
// order so the bars don't reshuffle between rebuilds. It reports the total when
// it had to cap, so the title can admit it.
func (s *dash) svcOrder() (order []int, capped int) {
	order = make([]int, len(s.res.SvcCount))
	for i := range order {
		order[i] = i
	}
	sort.SliceStable(order, func(a, b int) bool { return s.res.SvcCount[order[a]] > s.res.SvcCount[order[b]] })
	// Services with nothing in the current filter would draw as empty slots.
	for len(order) > 0 && s.res.SvcCount[order[len(order)-1]] == 0 {
		order = order[:len(order)-1]
	}
	if len(order) > maxSvcBars {
		capped, order = len(order), order[:maxSvcBars]
	}
	sort.Ints(order)
	return order, capped
}

func svcTitle(capped int) string {
	if capped == 0 {
		return "p95 by service"
	}
	return fmt.Sprintf("p95 by service · top %d of %d", maxSvcBars, capped)
}

// byService is where a fleet-wide incident becomes obvious: one service's p95
// climbs away from the others and its bar turns.
func (s *dash) byService(th theme.Theme, order []int) widget.Widget {
	pairs := make([]chart.Pair, 0, len(order))
	for _, i := range order {
		pairs = append(pairs, chart.Pair{Label: shortName(svcDict.Name(uint16(i))), Value: float64(s.res.SvcP95[i]) / 1000})
	}
	data := chart.Pairs(pairs)
	for i := range data {
		switch {
		case data[i].Y > 400:
			data[i].Color = th.Danger
		case data[i].Y > 150:
			data[i].Color = th.Warning
		default:
			data[i].Color = th.ChartAt(2)
		}
	}
	return chart.Chart{
		Marks:      []chart.Mark{chart.BarMark{Data: data}},
		XAxis:      chart.Axis{Ticks: len(data)},
		YAxis:      chart.Axis{Ticks: 3, Format: func(v float64) string { return fmt.Sprintf("%.0fms", v) }},
		LabelColor: th.Text, AxisColor: th.Muted, GridColor: th.Border,
	}
}

var statusNames = []string{"All", "2xx", "4xx", "5xx"}

func (s *dash) filters(th theme.Theme, narrow bool) widget.Widget {
	svcOpts := append([]string{"All services"}, svcDict.Names()...)
	search := theme.Field{
		Value:       s.q.Text,
		Placeholder: "Filter by service, route, host, or trace prefix…",
		OnChange:    func(v string) { s.setQuery(func(q *Query) { q.Text = v }) },
	}
	status := theme.Segmented{Options: statusNames, Selected: s.q.Status,
		OnChange: func(i int) { s.setQuery(func(q *Query) { q.Status = i }) }}
	svc := theme.Dropdown{Options: svcOpts, Selected: s.q.Svc + 1,
		OnChange: func(i int) { s.setQuery(func(q *Query) { q.Svc = i - 1 }) }}

	if narrow {
		return widget.Flex{
			Axis:       layout.Vertical,
			CrossAlign: layout.CrossStretch,
			Children: []widget.Widget{
				search,
				widget.Sized{H: 8},
				status,
			},
		}
	}
	row := widget.Row(
		widget.Expand(search),
		widget.Sized{W: 10},
		widget.Sized{W: 230, Child: status},
		widget.Sized{W: 10},
		widget.Sized{W: 170, Child: svc},
	)
	row.CrossAlign = layout.CrossCenter
	return row
}

// fullCols is the desktop column set. narrowCols keeps only what survives a
// phone-width table; both index the same colX constants, so a header tap sorts
// the same way in either layout.
var (
	fullCols   = []int{colTime, colAge, colService, colRoute, colHost, colTrace, colStatus, colLatency, colBytes}
	narrowCols = []int{colTime, colService, colStatus, colLatency}
)

// Fixed widths are sized to their widest real content plus the table's column
// gap — a monospaced "11:23:54.728" is about 94 points at the label size, and a
// column narrower than that lets the timestamp run into the next one.
var colSpec = map[int]theme.Col{
	colTime:    {Title: "Time", Width: 112},
	colAge:     {Title: "Age", Width: 62, Align: theme.AlignEnd},
	colService: {Title: "Service", Flex: 1},
	colRoute:   {Title: "Route", Flex: 3},
	colHost:    {Title: "Host", Width: 96},
	colTrace:   {Title: "Trace", Width: 84},
	colStatus:  {Title: "Status", Width: 66, Align: theme.AlignCenter},
	colLatency: {Title: "Latency", Width: 86, Align: theme.AlignEnd},
	colBytes:   {Title: "Bytes", Width: 82, Align: theme.AlignEnd},
}

func (s *dash) table(th theme.Theme, narrow bool) widget.Widget {
	set := fullCols
	if narrow {
		set = narrowCols
	}
	s.lastCols = set
	cols := make([]theme.Col, len(set))
	for i, c := range set {
		cols[i] = colSpec[c]
	}
	// Sorting is reported against the logical column, not the visible one, so
	// the indicator lands on the right header after a layout change.
	sortAt := -1
	for i, c := range set {
		if c == s.q.SortCol {
			sortAt = i
		}
	}
	return theme.Table{
		Columns:    cols,
		Count:      len(s.res.View),
		RowHeight:  28,
		Selectable: true,
		Selected:   s.selected,
		OnTapRow:   func(i int) { s.SetState(func() { s.selected = i }) },
		Sortable:   true,
		SortCol:    sortAt,
		SortDesc:   s.q.Desc,
		OnSort: func(i int, desc bool) {
			s.setQuery(func(q *Query) { q.SortCol, q.Desc = set[i], desc })
		},
		Cell: func(row, col int) widget.Widget { return s.cell(th, row, set[col]) },
	}
}

// cell builds one visible cell. It is called only for rows the viewport shows —
// twenty-odd of them — which is what makes a column like Age, recomputed from
// the clock on every frame, cost nothing across a hundred thousand rows.
func (s *dash) cell(th theme.Theme, row, col int) widget.Widget {
	if row < 0 || row >= len(s.res.View) {
		return nil
	}
	if s.cellCount != nil {
		*s.cellCount++
	}
	sp := s.res.Rows[s.res.View[row]]
	mono := func(txt string, c paint.Color) widget.Widget {
		return widget.Text{Value: txt, Font: "mono", Size: th.Type.Label, Color: c}
	}
	switch col {
	case colTime:
		return mono(s.store.Wall(sp.At).Format("15:04:05.000"), th.Muted)
	case colAge:
		return mono(fmtAge(s.store.Now()-sp.At), th.Muted)
	case colService:
		return widget.Text{Value: svcDict.Name(sp.Svc), Size: th.Type.Label, Color: th.Text, Ellipsis: true, MaxLines: 1}
	case colRoute:
		return widget.Text{Value: routeDict.Name(sp.Route), Size: th.Type.Label, Color: th.Text, Ellipsis: true, MaxLines: 1}
	case colHost:
		return mono(hostDict.Name(sp.Host), th.Muted)
	case colTrace:
		// The column shows the leading digits every tracing UI shows; the full
		// 32 are in the detail strip, where they can be read and copied.
		return mono(sp.TraceHex()[:8], th.Muted)
	case colStatus:
		return mono(fmt.Sprint(sp.Code), statusColor(th, sp.Code))
	case colLatency:
		return mono(fmtDur(sp.Dur), latencyColor(th, sp.Dur))
	default:
		if sp.Bytes < 0 { // the capture didn't record a response size
			return mono("—", th.Muted)
		}
		return mono(commas(int64(sp.Bytes)), th.Muted)
	}
}

// detail is the strip under the table describing the selected row in full —
// the columns the grid had to truncate, plus the trace ID to search on.
func (s *dash) detail(th theme.Theme) widget.Widget {
	if s.selected < 0 || s.selected >= len(s.res.View) {
		return widget.Sized{H: 0}
	}
	sp := s.res.Rows[s.res.View[s.selected]]
	line := fmt.Sprintf("%s  %s  %s  on %s  →  %d in %s, %s",
		sp.TraceHex(), svcDict.Name(sp.Svc), routeDict.Name(sp.Route), hostDict.Name(sp.Host),
		sp.Code, fmtDur(sp.Dur), fmtBytes(sp.Bytes))
	return widget.Padding{Insets: geom.Insets{Top: 10},
		Child: widget.Align{X: 0, Y: 0.5,
			Child: widget.Text{Value: line, Font: "mono", Size: th.Type.Caption,
				Color: th.Text, Ellipsis: true, MaxLines: 1}}}
}

func statusColor(th theme.Theme, code uint16) paint.Color {
	switch {
	case code >= 500:
		return th.Danger
	case code >= 400:
		return th.Warning
	default:
		return th.Success
	}
}

func latencyColor(th theme.Theme, us int32) paint.Color {
	switch {
	case us > 1_000_000:
		return th.Danger
	case us > 200_000:
		return th.Warning
	default:
		return th.Text
	}
}

// --- Formatting --------------------------------------------------------------

func fmtDur(us int32) string {
	switch {
	case us <= 0:
		return "—"
	case us < 1000:
		return fmt.Sprintf("%d µs", us)
	case us < 10_000:
		return fmt.Sprintf("%.2f ms", float64(us)/1000)
	case us < 1_000_000:
		return fmt.Sprintf("%.1f ms", float64(us)/1000)
	default:
		return fmt.Sprintf("%.2f s", float64(us)/1e6)
	}
}

func fmtAge(ms int32) string {
	switch {
	case ms < 0:
		return "0.0s"
	case ms < 60_000:
		return fmt.Sprintf("%.1fs", float64(ms)/1000)
	default:
		return fmt.Sprintf("%dm", ms/60_000)
	}
}

func fmtBytes(b int32) string {
	if b < 0 {
		return "size unrecorded"
	}
	if b < 1024 {
		return fmt.Sprintf("%d B", b)
	}
	return fmt.Sprintf("%.1f kB", float64(b)/1024)
}

// commas groups an integer for reading: 100000 → "100,000".
func commas(n int64) string {
	s := fmt.Sprint(n)
	neg := ""
	if s[0] == '-' {
		neg, s = "-", s[1:]
	}
	out := make([]byte, 0, len(s)+len(s)/3)
	for i := 0; i < len(s); i++ {
		if i > 0 && (len(s)-i)%3 == 0 {
			out = append(out, ',')
		}
		out = append(out, s[i])
	}
	return neg + string(out)
}

// maxSvcBars is how many services the p95 chart can label legibly at the width
// it gets. A capture with more is truncated to the busiest — and says so, rather
// than quietly implying the rest are fine.
const maxSvcBars = 8

// shortName trims a service name to what fits under one bar.
func shortName(s string) string {
	if len(s) <= 7 {
		return s
	}
	return s[:6] + "…"
}

// loadButtons offers the two ways into real data: the bundled sample capture,
// and a file off the user's disk where the platform gives us a picker. On a
// platform without one ctx.FilePicker() is nil and the button simply isn't
// there — the capability layer's whole contract in two lines.
func (s *dash) loadButtons() widget.Widget {
	kids := []widget.Widget{
		theme.Button{Label: "Sample OTLP", OnTap: s.loadSample},
	}
	if s.ctx.FilePicker() != nil {
		kids = append(kids, widget.Sized{W: 8},
			theme.Button{Label: "Open OTLP…", OnTap: s.openOTLP})
	}
	kids = append(kids, widget.Sized{W: 14})
	return widget.Row(kids...)
}

func (s *dash) loadSample() { s.load(strings.NewReader(sampleOTLP), "otlp-sample.json") }

func (s *dash) openOTLP() {
	s.ctx.FilePicker().Open(shell.OpenOptions{Accept: []string{".json", "application/json"}},
		func(files []shell.PickedFile, err error) {
			if err != nil || len(files) == 0 {
				return
			}
			s.load(bytes.NewReader(files[0].Data), files[0].Name)
		})
}

// load decodes a capture into a fresh vocabulary and, only if that succeeds,
// installs both. A failed decode leaves the current dataset exactly as it was —
// which is why the decoder interns into a vocabulary it is handed rather than
// into the live one.
func (s *dash) load(r io.Reader, name string) {
	v := newVocab()
	spans, err := DecodeOTLP(r, v)
	s.SetState(func() {
		if err != nil {
			s.loadErr = err.Error()
			return
		}
		s.loadErr = ""
		s.live = false
		useVocab(v)
		s.store.Replace(spans, name) // also stops the synthetic producer
		s.q = Query{Svc: -1, SortCol: colTime, Desc: true}
		s.selected = -1
		s.dirty = true
	})
}