mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-07 23:07:48 +02:00
admin: key chart samples by timestamp instead of slice index
Series were paired positionally, so a failed scrape, a missing metric, a counter reset or a histogram interval with no observations shifted a series and plotted unrelated samples at the same instant. Samples now carry their scrape time, charts lay every series out on a shared time axis, and gaps break the line rather than being drawn through.
This commit is contained in:
1 parent
f7f7505f60
commit
c4fe3b3cf1
2 files changed
+105
-64
No files matched your search
+101
-63
@@ -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(`<svg viewBox="0 0 %g %g" preserveAspectRatio="xMidYMid meet" style="width:100%%;height:auto"><line x1="%g" y1="%g" x2="%g" y2="%g" stroke="#e3e6f0" stroke-width="1"/><text x="%g" y="%g" text-anchor="middle" font-size="10" fill="#858796">collecting data…</text></svg>`,
|
||||
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, `<text x="%g" y="%.1f" text-anchor="end" font-size="9" fill="#858796">%s</text>`, padL-6, y+3, html.EscapeString(FormatChartValue(v, opts.Unit)))
|
||||
}
|
||||
for _, i := range []int{0, n / 2, n - 1} {
|
||||
fmt.Fprintf(&s, `<text x="%.1f" y="%g" text-anchor="middle" font-size="9" fill="#858796">%s</text>`, px(i), h-6, html.EscapeString(xLabel(opts, i, n)))
|
||||
fmt.Fprintf(&s, `<text x="%.1f" y="%g" text-anchor="middle" font-size="9" fill="#858796">%s</text>`, px(i), h-6, html.EscapeString(xLabel(opts, times, i)))
|
||||
}
|
||||
if opts.Threshold != nil {
|
||||
fmt.Fprintf(&s, `<line x1="%g" y1="%.1f" x2="%g" y2="%.1f" stroke="%s" stroke-width="1" stroke-dasharray="4 3"/>`, 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, `<path d="%s L%.1f %.1f L%.1f %.1f Z" fill="%s" opacity="0.12"/>`, 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, `<circle cx="%.1f" cy="%.1f" r="2" fill="%s"/>`, px(slot[run[0].T]), py(run[0].V), color)
|
||||
continue
|
||||
}
|
||||
if se.Area {
|
||||
base := py(min)
|
||||
fmt.Fprintf(&s, `<path d="%s L%.1f %.1f L%.1f %.1f Z" fill="%s" opacity="0.12"/>`,
|
||||
d.String(), px(slot[run[len(run)-1].T]), base, px(slot[run[0].T]), base, color)
|
||||
}
|
||||
fmt.Fprintf(&s, `<path d="%s" fill="none" stroke="%s" stroke-width="1.8" stroke-linejoin="round"/>`, d.String(), color)
|
||||
}
|
||||
fmt.Fprintf(&s, `<path d="%s" fill="none" stroke="%s" stroke-width="1.8" stroke-linejoin="round"/>`, d.String(), color)
|
||||
}
|
||||
return fmt.Sprintf(`<svg viewBox="0 0 %g %g" preserveAspectRatio="xMidYMid meet" style="width:100%%;height:auto">%s</svg>`, 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user