From d29b867fab6a49214c5cf3eca4ba1362b9821dea Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 14 Sep 2026 21:51:08 -0700 Subject: [PATCH] admin: export the chart API for templates Templates live in the app package, so the chart types and renderer must be exported. Also anchors the y-axis at zero, left-pads shorter series so multi-series charts stay aligned, escapes text nodes, renders a 'collecting data' placeholder before enough samples exist, and adds a seconds unit for latency quantiles. --- weed/admin/dash/chart.go | 254 ++++++++++++++++++++++++--------------- 1 file changed, 154 insertions(+), 100 deletions(-) diff --git a/weed/admin/dash/chart.go b/weed/admin/dash/chart.go index 76c10aca7..922f92b82 100644 --- a/weed/admin/dash/chart.go +++ b/weed/admin/dash/chart.go @@ -2,81 +2,155 @@ package dash import ( "fmt" + "html" "strings" ) -type chartSeries struct { - name string - color string - data []float64 - area bool +// Chart palette, matching the muted colors in static/css/admin.css. +const ( + ChartPrimary = "#6b8caf" + ChartSuccess = "#5a8a72" + ChartInfo = "#6a9aaa" + ChartWarning = "#b8995e" + ChartDanger = "#a5615c" +) + +// Y-axis value formats. +const ( + UnitCount = "" + UnitBytes = "bytes" + UnitBytesPS = "bps" + UnitSeconds = "seconds" + UnitPercent = "pct" +) + +type ChartSeries struct { + Name string + Color string + Data []float64 + Area bool } -type chartOptions struct { - unit string - threshold *float64 - labels []string +type ChartOptions struct { + Unit string + // Threshold draws a dashed reference line, e.g. a disk-usage limit. + Threshold *float64 + // Labels are x-axis tick labels, oldest first. Falls back to relative + // sample offsets when empty. + Labels []string } -func renderChartSVG(series []chartSeries, opts chartOptions) string { +// 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. +func RenderChart(series []ChartSeries, opts ChartOptions) string { const w, h = 560.0, 190.0 - const l, r, t, b = 46.0, 8.0, 10.0, 22.0 + const padL, padR, padT, padB = 46.0, 8.0, 10.0, 22.0 + plotH := h - padT - padB - if len(series) == 0 || len(series[0].data) < 2 { - return flatChart(w, h, l, r, t, b) - } - n := len(series[0].data) - min, max := computeRange(series, opts.threshold) - if min > 0 { - min = 0 - } - if max == min { - max = min + 1 + series = alignSeries(series) + n := seriesLen(series) + if n < 2 { + return fmt.Sprintf(`collecting data…`, + w, h, padL, padT+plotH/2, w-padR, padT+plotH/2, w/2, padT+plotH/2-8) } + + min, max := chartRange(series, opts.Threshold) span := max - min - px := func(i int) float64 { return l + float64(i)*(w-l-r)/float64(n-1) } - py := func(v float64) float64 { return t + (h-t-b)*(1-(v-min)/span) } + px := func(i int) float64 { return padL + float64(i)*(w-padL-padR)/float64(n-1) } + py := func(v float64) float64 { return padT + plotH*(1-(v-min)/span) } var s strings.Builder for g := 0; g <= 4; g++ { v := min + span*float64(g)/4 y := py(v) - fmt.Fprintf(&s, ``, l, y, w-r, y) - fmt.Fprintf(&s, `%s`, l-6, y+3, formatChartValue(v, opts.unit)) + fmt.Fprintf(&s, ``, padL, y, w-padR, y) + 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, xLabel(opts, i, n)) + fmt.Fprintf(&s, `%s`, px(i), h-6, html.EscapeString(xLabel(opts, i, n))) } - if opts.threshold != nil { - y := py(*opts.threshold) - fmt.Fprintf(&s, ``, l, y, w-r, y) + 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 { + 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)) } } - if se.area { - fmt.Fprintf(&s, ``, d.String(), px(n-1), py(0), px(0), py(0), se.color) + color := se.Color + if color == "" { + color = ChartPrimary } - fmt.Fprintf(&s, ``, d.String(), se.color) + if se.Area { + base := py(min) + fmt.Fprintf(&s, ``, d.String(), px(n-1), base, px(0), base, color) + } + fmt.Fprintf(&s, ``, d.String(), color) } return fmt.Sprintf(`%s`, w, h, s.String()) } -func flatChart(w, h, l, r, t, b float64) string { - return fmt.Sprintf(``, w, h, l, (t + (h-t-b)/2), w-r, (t + (h-t-b)/2)) +// RenderLegend renders series names and colors as Bootstrap-friendly markup. +func RenderLegend(series []ChartSeries) string { + var b strings.Builder + b.WriteString(`
`) + for _, se := range series { + color := se.Color + if color == "" { + color = ChartPrimary + } + fmt.Fprintf(&b, `%s`, + html.EscapeString(color), html.EscapeString(se.Name)) + } + b.WriteString(`
`) + return b.String() } -func computeRange(series []chartSeries, threshold *float64) (float64, float64) { - min, max := 1e9, -1e9 +// seriesLen returns the sample count shared by all series. +func seriesLen(series []ChartSeries) int { + n := 0 for _, se := range series { - for _, v := range se.data { + 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 } @@ -88,87 +162,67 @@ func computeRange(series []chartSeries, threshold *float64) (float64, float64) { if threshold != nil && *threshold > max { max = *threshold * 1.15 } + if max == min { + max = min + 1 + } return min, max } -func xLabel(opts chartOptions, i, n int) string { - if i < len(opts.labels) { - return opts.labels[i] +func xLabel(opts ChartOptions, i, n int) string { + if i < len(opts.Labels) { + return opts.Labels[i] } - return fmt.Sprintf("-%dm", n-1-i) + if i == n-1 { + return "now" + } + return fmt.Sprintf("-%d", n-1-i) } -func formatChartValue(v float64, unit string) string { +// FormatChartValue renders an axis value in the given unit. +func FormatChartValue(v float64, unit string) string { switch unit { - case "bytes": - return chartFormatBytes(int64(v)) - case "bps": - return chartFormatBytes(int64(v)) + "/s" - case "ms": - return fmt.Sprintf("%.0f ms", v) - case "pct": + case UnitBytes: + return formatChartBytes(v) + case UnitBytesPS: + return formatChartBytes(v) + "/s" + case UnitSeconds: + if v < 1 { + return fmt.Sprintf("%.0f ms", v*1000) + } + return fmt.Sprintf("%.2f s", v) + case UnitPercent: return fmt.Sprintf("%.0f%%", v) default: - if v >= 1000 { + switch { + case v >= 1e6: + return fmt.Sprintf("%.1fM", v/1e6) + case v >= 1000: return fmt.Sprintf("%.1fk", v/1000) - } - if v == float64(int64(v)) { + case v == float64(int64(v)): return fmt.Sprintf("%d", int64(v)) + default: + return fmt.Sprintf("%.2f", v) } - return fmt.Sprintf("%.1f", v) } } -func chartFormatBytes(b int64) string { - const u = 1024 - if b < u { - return fmt.Sprintf("%d B", b) +func formatChartBytes(v float64) string { + const unit = 1024.0 + if v < unit { + return fmt.Sprintf("%.0f B", v) } - div, exp := int64(u), 0 - for n := b / u; n >= u; n /= u { - div *= u + div, exp := unit, 0 + for v/div >= unit && exp < 5 { + div *= unit exp++ } - return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) + return fmt.Sprintf("%.1f %cB", v/div, "KMGTPE"[exp]) } -func renderLegend(series []chartSeries) string { - var b strings.Builder - b.WriteString(`
`) - for _, se := range series { - fmt.Fprintf(&b, `%s`, se.color, se.name) +// LatestValue returns the newest sample, or 0 when there is no data. +func LatestValue(data []float64) float64 { + if len(data) == 0 { + return 0 } - b.WriteString(`
`) - return b.String() -} - -func sampleTimes(n int) []string { - out := make([]string, n) - for i := 0; i < n; i++ { - m := -(n - 1 - i) - if m == 0 { - out[i] = "now" - } else { - out[i] = fmt.Sprintf("%dm", m) - } - } - return out -} - -func seriesFromSamples(samples []metricsSample) []float64 { - out := make([]float64, len(samples)) - for i, s := range samples { - if v, ok := s.values[""]; ok { - out[i] = v - } - } - return out -} - -func timeLabels(samples []metricsSample) []string { - out := make([]string, len(samples)) - for i, s := range samples { - out[i] = s.t.Format("15:04") - } - return out + return data[len(data)-1] }