// Package chart is the range chart engine shared by every system that draws // history: what a chart is, the JSON endpoint uPlot reads, and the page the // range-chart component renders. package chart import ( "context" "encoding/json" "fmt" "math" "net/http" "net/url" "sort" "strconv" "strings" "time" "armdash/internal/promql" "armdash/internal/system" ) // Chart describes one graphable metric. Adding a graph is adding one of these. // // A chart is either a single Query or a list of Series, for lines that live in // different metrics. type Chart struct { Slug string Title string Unit string Query string Series []Series // FromZero pins the y axis at zero. True where the distance from zero is // the point, percentages and watts. False for temperatures, where the // interesting spread is a few degrees and a zero based axis would flatten // every line on top of every other. FromZero bool // Bucket makes the chart bars, one per bucket of the length it returns for // the chosen window. A query reads that length as $bucket: sampled once per // bucket, increase(x[$bucket]) counts every moment exactly once. Bucket func(window time.Duration) time.Duration // Window is the range the page opens on; zero is the shortest. Window time.Duration } // Series is one line, or several: an empty Name means the query returns more // than one series and each is named by the set's Names. type Series struct { Name string Query string } func (c Chart) lines() []Series { if len(c.Series) > 0 { return c.Series } return []Series{{Query: c.Query}} } // Windows is what the range selector offers. Refresh is how often an open // chart reloads: a year gains nothing visible in a minute, an hour does. var Windows = []struct { Span time.Duration Label string Refresh time.Duration }{ {time.Hour, "last hour", 30 * time.Second}, {6 * time.Hour, "last 6 hours", time.Minute}, {24 * time.Hour, "last day", 2 * time.Minute}, {7 * 24 * time.Hour, "last week", 10 * time.Minute}, {30 * 24 * time.Hour, "last 30 days", 30 * time.Minute}, {365 * 24 * time.Hour, "last year", time.Hour}, } // HourOrDay is a bucket rule: per hour up to a day, per day beyond. func HourOrDay(window time.Duration) time.Duration { if window <= 24*time.Hour { return time.Hour } return 24 * time.Hour } // Namer names a line from the labels its query kept. An empty name falls back // to the chart title. type Namer func(labels map[string]string) string // Set is one system's charts and the endpoint that feeds them. type Set struct { Prom *promql.Client Charts []Chart // Names is asked once per request, so a system can look up what it names // lines from once rather than once per line. Names func(ctx context.Context) Namer api string now func() time.Time } // Register serves the chart data at prefix+"range". func (s *Set) Register(mux *http.ServeMux, prefix string) { s.api = prefix mux.HandleFunc("GET "+prefix+"range", s.handleRange) } // Find returns the chart with the given slug. func Find(charts []Chart, slug string) (Chart, bool) { for _, c := range charts { if c.Slug == slug { return c, true } } return Chart{}, false } // Nav is one sidebar entry per chart. func Nav(charts []Chart) []system.NavItem { nav := make([]system.NavItem, 0, len(charts)) for _, c := range charts { nav = append(nav, system.NavItem{Slug: c.Slug, Title: c.Title}) } return nav } // Page is what the range-chart component draws. type Page struct { Top system.PageTop URL string Queries []Series Unconfigured bool } // Page builds a chart's page: the infobar with the range selector, and the // queries for the box under the chart. func (s *Set) Page(c Chart, unconfigured bool) Page { open := c.Window if open == 0 { open = Windows[0].Span } var opts strings.Builder for _, w := range Windows { sel := "" if w.Span == open { sel = " selected" } fmt.Fprintf(&opts, ``, int(w.Span/time.Second), int(w.Refresh/time.Second), sel, w.Label) } top := system.PageTop{Title: c.Title} top.Actionf(`loading`) top.Actionf(`
`, opts.String()) return Page{ Top: top, URL: s.api + "range?metric=" + url.QueryEscape(c.Slug), Queries: c.lines(), Unconfigured: unconfigured, } } // response is the payload the range-chart component draws. type response struct { Unit string `json:"unit"` FromZero bool `json:"fromZero"` Bucket int64 `json:"bucket,omitempty"` Time []int64 `json:"time"` Series []jsonSeries `json:"series"` } // jsonSeries is one line as the chart consumes it. Data is pointers so a // missing sample can be null rather than a plausible looking zero. type jsonSeries struct { Name string `json:"name"` Data []*float64 `json:"data"` } func (s *Set) handleRange(w http.ResponseWriter, r *http.Request) { c, ok := Find(s.Charts, r.URL.Query().Get("metric")) if !ok { http.Error(w, "unknown metric", http.StatusNotFound) return } secs, err := strconv.Atoi(r.URL.Query().Get("window")) if err != nil || secs <= 0 { secs = 86400 } now := time.Now() if s.now != nil { now = s.now() } start, end, step := span(c, time.Duration(secs)*time.Second, now) ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() name := Namer(func(map[string]string) string { return "" }) if s.Names != nil { name = s.Names(ctx) } type line struct { name string points []promql.Point } var lines []line for _, cs := range c.lines() { q := strings.ReplaceAll(cs.Query, "$bucket", seconds(step)) res, err := s.Prom.QueryRange(ctx, q, start, end, step) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } // No result is not an error: a drive may be unplugged, a device gone, // or a collector not installed yet. The line is simply absent. for _, sr := range res { n := cs.Name if n == "" { n = name(sr.Labels) } if n == "" { n = c.Title } lines = append(lines, line{name: n, points: sr.Points}) } } // Every query shares start, end and step, so Prometheus hands back the // same timestamp grid. A series with no data for part of the window is // missing those points entirely though, so the lines are aligned by // timestamp rather than by index. at := map[int64]int{} times := []int64{} for _, l := range lines { for _, p := range l.points { t := p.T.Unix() if _, ok := at[t]; !ok { at[t] = 0 times = append(times, t) } } } sort.Slice(times, func(i, j int) bool { return times[i] < times[j] }) for i, t := range times { at[t] = i } out := response{Unit: c.Unit, FromZero: c.FromZero, Time: times, Series: []jsonSeries{}} for _, l := range lines { data := make([]*float64, len(times)) for _, p := range l.points { // NaN and the infinities are legal Prometheus values and cannot be // encoded as JSON numbers. A nil slot draws a gap, which is what // they mean anyway. if math.IsNaN(p.V) || math.IsInf(p.V, 0) { continue } v := p.V data[at[p.T.Unix()]] = &v } out.Series = append(out.Series, jsonSeries{Name: l.name, Data: data}) } if c.Bucket != nil { // Prometheus stamps a bucket with the moment it ends; a bar is // labelled by the moment it starts. out.Bucket = int64(step / time.Second) out.Unit += " per " + bucketName(step) for i := range out.Time { out.Time[i] -= out.Bucket } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(out) } // span picks what to ask Prometheus for. A line chart aims for ~800 points // whatever the window, so a year costs no more to draw than an hour. A bar // chart steps by its bucket, on bucket boundaries, and runs to the end of the // bucket in progress so the last bar is the hour or the day so far. func span(c Chart, window time.Duration, now time.Time) (start, end time.Time, step time.Duration) { if c.Bucket == nil { step = max((window / 800).Truncate(time.Second), 15*time.Second) return now.Add(-window), now, step } step = c.Bucket(window) // Boundaries are counted in the server's local time, so a day is a // calendar day there rather than one starting at midnight UTC. Both ends // use today's offset, so a daylight saving change inside the window cannot // leave them a fraction of a bucket apart. _, off := now.Zone() b := int64(step / time.Second) floor := func(t time.Time) time.Time { u := t.Unix() + int64(off) return time.Unix(u-u%b-int64(off), 0) } return floor(now.Add(-window)).Add(step), floor(now).Add(step), step } func seconds(d time.Duration) string { return strconv.FormatInt(int64(d/time.Second), 10) + "s" } func bucketName(d time.Duration) string { switch d { case time.Hour: return "hour" case 24 * time.Hour: return "day" } return d.String() }