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(``,
+ 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(``, 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]
}