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(``,
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(``, 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) {