diff --git a/weed/admin/dash/chart.go b/weed/admin/dash/chart.go new file mode 100644 index 000000000..76c10aca7 --- /dev/null +++ b/weed/admin/dash/chart.go @@ -0,0 +1,174 @@ +package dash + +import ( + "fmt" + "strings" +) + +type chartSeries struct { + name string + color string + data []float64 + area bool +} + +type chartOptions struct { + unit string + threshold *float64 + labels []string +} + +func renderChartSVG(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 + + 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 + } + 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) } + + 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)) + } + for _, i := range []int{0, n / 2, n - 1} { + fmt.Fprintf(&s, `%s`, px(i), h-6, xLabel(opts, i, n)) + } + if opts.threshold != nil { + y := py(*opts.threshold) + fmt.Fprintf(&s, ``, l, y, w-r, y) + } + 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)) + } + } + if se.area { + fmt.Fprintf(&s, ``, d.String(), px(n-1), py(0), px(0), py(0), se.color) + } + fmt.Fprintf(&s, ``, d.String(), se.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)) +} + +func computeRange(series []chartSeries, threshold *float64) (float64, float64) { + min, max := 1e9, -1e9 + for _, se := range series { + for _, v := range se.data { + if v < min { + min = v + } + if v > max { + max = v + } + } + } + if threshold != nil && *threshold > max { + max = *threshold * 1.15 + } + return min, max +} + +func xLabel(opts chartOptions, i, n int) string { + if i < len(opts.labels) { + return opts.labels[i] + } + return fmt.Sprintf("-%dm", n-1-i) +} + +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": + return fmt.Sprintf("%.0f%%", v) + default: + if v >= 1000 { + return fmt.Sprintf("%.1fk", v/1000) + } + if v == float64(int64(v)) { + return fmt.Sprintf("%d", int64(v)) + } + return fmt.Sprintf("%.1f", v) + } +} + +func chartFormatBytes(b int64) string { + const u = 1024 + if b < u { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(u), 0 + for n := b / u; n >= u; n /= u { + div *= u + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(b)/float64(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) + } + 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 +}