diff --git a/weed/admin/dash/chart.go b/weed/admin/dash/chart.go index 219f760f1..1a6e0cbc7 100644 --- a/weed/admin/dash/chart.go +++ b/weed/admin/dash/chart.go @@ -3,7 +3,9 @@ package dash import ( "fmt" "html" + "sort" "strings" + "time" ) // Chart palette, matching the muted colors in static/css/admin.css. @@ -24,10 +26,17 @@ const ( UnitPercent = "pct" ) +// Point is one sample, keeping its scrape timestamp so series recorded at +// different times are never paired by index. +type Point struct { + T time.Time + V float64 +} + type ChartSeries struct { Name string Color string - Data []float64 + Data []Point Area bool } @@ -41,19 +50,23 @@ type ChartOptions struct { } // RenderChart draws a multi-series line chart as a self-contained inline SVG, -// with no JavaScript. Series must be equal length; shorter ones are left-padded -// so all series share the newest sample. +// with no JavaScript. Series are positioned on a shared time axis, so series +// with gaps or differing sample times stay correctly aligned. func RenderChart(series []ChartSeries, opts ChartOptions) string { const w, h = 560.0, 190.0 const padL, padR, padT, padB = 46.0, 8.0, 10.0, 22.0 plotH := h - padT - padB - series = alignSeries(series) - n := seriesLen(series) - if n < 2 { + times := unionTimes(series) + if len(times) < 2 { return fmt.Sprintf(`collecting data…`, w, h, padL, padT+plotH/2, w-padR, padT+plotH/2, w/2, padT+plotH/2-8) } + slot := make(map[time.Time]int, len(times)) + for i, t := range times { + slot[t] = i + } + n := len(times) min, max := chartRange(series, opts.Threshold) span := max - min @@ -69,33 +82,85 @@ func RenderChart(series []ChartSeries, opts ChartOptions) string { fmt.Fprintf(&s, `%s`, padL-6, y+3, html.EscapeString(FormatChartValue(v, opts.Unit))) } for _, i := range []int{0, n / 2, n - 1} { - fmt.Fprintf(&s, `%s`, px(i), h-6, html.EscapeString(xLabel(opts, i, n))) + fmt.Fprintf(&s, `%s`, px(i), h-6, html.EscapeString(xLabel(opts, times, i))) } if opts.Threshold != nil { fmt.Fprintf(&s, ``, padL, py(*opts.Threshold), w-padR, py(*opts.Threshold), ChartDanger) } for _, se := range series { - var d strings.Builder - for i, v := range se.Data { - if i == 0 { - fmt.Fprintf(&d, "M%.1f %.1f", px(i), py(v)) - } else { - fmt.Fprintf(&d, " L%.1f %.1f", px(i), py(v)) - } - } color := se.Color if color == "" { color = ChartPrimary } - if se.Area { - base := py(min) - fmt.Fprintf(&s, ``, d.String(), px(n-1), base, px(0), base, color) + // Break the path wherever the series has no sample, so gaps are not + // drawn as straight lines through missing time. + for _, run := range contiguousRuns(se.Data, slot) { + var d strings.Builder + for k, p := range run { + x, y := px(slot[p.T]), py(p.V) + if k == 0 { + fmt.Fprintf(&d, "M%.1f %.1f", x, y) + } else { + fmt.Fprintf(&d, " L%.1f %.1f", x, y) + } + } + if len(run) == 1 { + fmt.Fprintf(&s, ``, px(slot[run[0].T]), py(run[0].V), color) + continue + } + if se.Area { + base := py(min) + fmt.Fprintf(&s, ``, + d.String(), px(slot[run[len(run)-1].T]), base, px(slot[run[0].T]), base, color) + } + fmt.Fprintf(&s, ``, d.String(), color) } - fmt.Fprintf(&s, ``, d.String(), color) } return fmt.Sprintf(`%s`, w, h, s.String()) } +// unionTimes returns every timestamp present in any series, sorted. +func unionTimes(series []ChartSeries) []time.Time { + seen := map[time.Time]bool{} + var out []time.Time + for _, se := range series { + for _, p := range se.Data { + if !seen[p.T] { + seen[p.T] = true + out = append(out, p.T) + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Before(out[j]) }) + return out +} + +// contiguousRuns splits a series into runs of samples that occupy adjacent +// slots on the shared time axis. +func contiguousRuns(data []Point, slot map[time.Time]int) [][]Point { + sorted := make([]Point, 0, len(data)) + for _, p := range data { + if _, ok := slot[p.T]; ok { + sorted = append(sorted, p) + } + } + sort.Slice(sorted, func(i, j int) bool { return slot[sorted[i].T] < slot[sorted[j].T] }) + + var runs [][]Point + var cur []Point + for i, p := range sorted { + if i > 0 && slot[p.T] != slot[sorted[i-1].T]+1 { + runs = append(runs, cur) + cur = nil + } + cur = append(cur, p) + } + if len(cur) > 0 { + runs = append(runs, cur) + } + return runs +} + // RenderLegend renders series names and colors as Bootstrap-friendly markup. func RenderLegend(series []ChartSeries) string { var b strings.Builder @@ -112,50 +177,17 @@ func RenderLegend(series []ChartSeries) string { return b.String() } -// seriesLen returns the sample count shared by all series. -func seriesLen(series []ChartSeries) int { - n := 0 - for _, se := range series { - if len(se.Data) > n { - n = len(se.Data) - } - } - return n -} - -// alignSeries left-pads shorter series so every series ends on the newest -// sample. Series with no data are dropped. -func alignSeries(series []ChartSeries) []ChartSeries { - n := seriesLen(series) - if n == 0 { - return nil - } - out := make([]ChartSeries, 0, len(series)) - for _, se := range series { - if len(se.Data) == 0 { - continue - } - if len(se.Data) < n { - padded := make([]float64, n) - copy(padded[n-len(se.Data):], se.Data) - se.Data = padded - } - out = append(out, se) - } - return out -} - // chartRange picks the y-axis bounds. It anchors at zero so magnitudes stay // comparable, and never returns a zero span. func chartRange(series []ChartSeries, threshold *float64) (float64, float64) { min, max := 0.0, 0.0 for _, se := range series { - for _, v := range se.Data { - if v < min { - min = v + for _, p := range se.Data { + if p.V < min { + min = p.V } - if v > max { - max = v + if p.V > max { + max = p.V } } } @@ -168,14 +200,14 @@ func chartRange(series []ChartSeries, threshold *float64) (float64, float64) { return min, max } -func xLabel(opts ChartOptions, i, n int) string { +func xLabel(opts ChartOptions, times []time.Time, i int) string { if i < len(opts.Labels) { return opts.Labels[i] } - if i == n-1 { + if i == len(times)-1 { return "now" } - return fmt.Sprintf("-%d", n-1-i) + return times[i].Format("15:04") } // FormatChartValue renders an axis value in the given unit. @@ -229,10 +261,16 @@ func formatChartBytes(v float64) string { return fmt.Sprintf("%.1f %cB", v/div, "KMGTPE"[exp]) } -// LatestValue returns the newest sample, or 0 when there is no data. -func LatestValue(data []float64) float64 { +// LatestValue returns the newest sample's value, or 0 when there is no data. +func LatestValue(data []Point) float64 { if len(data) == 0 { return 0 } - return data[len(data)-1] + newest := data[0] + for _, p := range data[1:] { + if p.T.After(newest.T) { + newest = p + } + } + return newest.V } diff --git a/weed/admin/dash/metrics_store.go b/weed/admin/dash/metrics_store.go index 7f3a949ea..da302a4d5 100644 --- a/weed/admin/dash/metrics_store.go +++ b/weed/admin/dash/metrics_store.go @@ -86,7 +86,10 @@ func (s *metricsStore) matchFiltered(sourcePrefix, name string, keep func(map[st if ser.name != name { continue } - if ser.source != sourcePrefix && !strings.HasPrefix(ser.source, sourcePrefix+"/") { + // An empty prefix matches every source, for metrics whose name already + // identifies the component (e.g. SeaweedFS_s3_*, which a combined + // "weed server" process exports from the filer's registry). + if sourcePrefix != "" && ser.source != sourcePrefix && !strings.HasPrefix(ser.source, sourcePrefix+"/") { continue } if keep != nil && !keep(ser.labels) {