diff --git a/audit/internal/webui/charts_svg.go b/audit/internal/webui/charts_svg.go
new file mode 100644
index 0000000..ee896e3
--- /dev/null
+++ b/audit/internal/webui/charts_svg.go
@@ -0,0 +1,713 @@
+package webui
+
+import (
+ "fmt"
+ "math"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "bee/audit/internal/platform"
+)
+
+type chartTimelineSegment struct {
+ Start time.Time
+ End time.Time
+ Active bool
+}
+
+type chartScale struct {
+ Min float64
+ Max float64
+ Ticks []float64
+}
+
+type chartLayout struct {
+ Width int
+ Height int
+ PlotLeft int
+ PlotRight int
+ PlotTop int
+ PlotBottom int
+}
+
+type metricChartSeries struct {
+ Name string
+ AxisTitle string
+ Color string
+ Values []float64
+}
+
+var metricChartPalette = []string{
+ "#5794f2",
+ "#73bf69",
+ "#f2cc0c",
+ "#ff9830",
+ "#f2495c",
+ "#b877d9",
+ "#56d2f7",
+ "#8ab8ff",
+ "#9adf8f",
+ "#ffbe5c",
+}
+
+func renderMetricChartSVG(title string, labels []string, times []time.Time, datasets [][]float64, names []string, yMin, yMax *float64, canvasHeight int, timeline []chartTimelineSegment) ([]byte, error) {
+ pointCount := len(labels)
+ if len(times) > pointCount {
+ pointCount = len(times)
+ }
+ if pointCount == 0 {
+ pointCount = 1
+ labels = []string{""}
+ times = []time.Time{time.Time{}}
+ }
+ if len(labels) < pointCount {
+ padded := make([]string, pointCount)
+ copy(padded, labels)
+ labels = padded
+ }
+ if len(times) < pointCount {
+ times = synthesizeChartTimes(times, pointCount)
+ }
+ for i := range datasets {
+ if len(datasets[i]) == 0 {
+ datasets[i] = make([]float64, pointCount)
+ }
+ }
+
+ mn, avg, mx := globalStats(datasets)
+ if mx > 0 {
+ title = fmt.Sprintf("%s ↓%s ~%s ↑%s",
+ title,
+ chartLegendNumber(mn),
+ chartLegendNumber(avg),
+ chartLegendNumber(mx),
+ )
+ }
+
+ legendItems := []metricChartSeries{}
+ for i, name := range names {
+ color := metricChartPalette[i%len(metricChartPalette)]
+ values := make([]float64, pointCount)
+ if i < len(datasets) {
+ copy(values, coalesceDataset(datasets[i], pointCount))
+ }
+ legendItems = append(legendItems, metricChartSeries{
+ Name: name,
+ Color: color,
+ Values: values,
+ })
+ }
+
+ scale := singleAxisChartScale(datasets, yMin, yMax)
+ layout := singleAxisChartLayout(canvasHeight, len(legendItems))
+ start, end := chartTimeBounds(times)
+
+ var b strings.Builder
+ writeSVGOpen(&b, layout.Width, layout.Height)
+ writeChartFrame(&b, title, layout.Width, layout.Height)
+ writeTimelineIdleSpans(&b, layout, start, end, timeline)
+ writeVerticalGrid(&b, layout, times, pointCount, 8)
+ writeHorizontalGrid(&b, layout, scale)
+ writeTimelineBoundaries(&b, layout, start, end, timeline)
+ writePlotBorder(&b, layout)
+ writeSingleAxisY(&b, layout, scale)
+ writeXAxisLabels(&b, layout, times, labels, start, end, 8)
+ for _, item := range legendItems {
+ writeSeriesPolyline(&b, layout, times, start, end, item.Values, scale, item.Color)
+ }
+ writeLegend(&b, layout, legendItems)
+ writeSVGClose(&b)
+ return []byte(b.String()), nil
+}
+
+func renderGPUOverviewChartSVG(idx int, samples []platform.LiveMetricSample, timeline []chartTimelineSegment) ([]byte, bool, error) {
+ temp := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.TempC })
+ power := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.PowerW })
+ coreClock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.ClockMHz })
+ memClock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.MemClockMHz })
+ if temp == nil && power == nil && coreClock == nil && memClock == nil {
+ return nil, false, nil
+ }
+ labels := sampleTimeLabels(samples)
+ times := sampleTimes(samples)
+ svg, err := drawGPUOverviewChartSVG(
+ fmt.Sprintf("GPU %d Overview", idx),
+ labels,
+ times,
+ []metricChartSeries{
+ {Name: "Temp C", Values: coalesceDataset(temp, len(labels)), Color: "#f05a5a", AxisTitle: "Temp C"},
+ {Name: "Power W", Values: coalesceDataset(power, len(labels)), Color: "#ffb357", AxisTitle: "Power W"},
+ {Name: "Core Clock MHz", Values: coalesceDataset(coreClock, len(labels)), Color: "#73bf69", AxisTitle: "Core MHz"},
+ {Name: "Memory Clock MHz", Values: coalesceDataset(memClock, len(labels)), Color: "#5794f2", AxisTitle: "Memory MHz"},
+ },
+ timeline,
+ )
+ if err != nil {
+ return nil, false, err
+ }
+ return svg, true, nil
+}
+
+func drawGPUOverviewChartSVG(title string, labels []string, times []time.Time, series []metricChartSeries, timeline []chartTimelineSegment) ([]byte, error) {
+ if len(series) != 4 {
+ return nil, fmt.Errorf("gpu overview requires 4 series, got %d", len(series))
+ }
+ const (
+ width = 1400
+ height = 840
+ plotLeft = 180
+ plotRight = 1220
+ plotTop = 96
+ plotBottom = 660
+ )
+ const (
+ leftOuterAxis = 72
+ leftInnerAxis = 132
+ rightInnerAxis = 1268
+ rightOuterAxis = 1328
+ )
+ layout := chartLayout{
+ Width: width,
+ Height: height,
+ PlotLeft: plotLeft,
+ PlotRight: plotRight,
+ PlotTop: plotTop,
+ PlotBottom: plotBottom,
+ }
+ axisX := []int{leftOuterAxis, leftInnerAxis, rightInnerAxis, rightOuterAxis}
+ pointCount := len(labels)
+ if len(times) > pointCount {
+ pointCount = len(times)
+ }
+ if pointCount == 0 {
+ pointCount = 1
+ labels = []string{""}
+ times = []time.Time{time.Time{}}
+ }
+ if len(labels) < pointCount {
+ padded := make([]string, pointCount)
+ copy(padded, labels)
+ labels = padded
+ }
+ if len(times) < pointCount {
+ times = synthesizeChartTimes(times, pointCount)
+ }
+ for i := range series {
+ if len(series[i].Values) == 0 {
+ series[i].Values = make([]float64, pointCount)
+ }
+ }
+
+ scales := make([]chartScale, len(series))
+ for i := range series {
+ min, max := chartSeriesBounds(series[i].Values)
+ ticks := chartNiceTicks(min, max, 8)
+ scales[i] = chartScale{
+ Min: ticks[0],
+ Max: ticks[len(ticks)-1],
+ Ticks: ticks,
+ }
+ }
+ start, end := chartTimeBounds(times)
+
+ var b strings.Builder
+ writeSVGOpen(&b, width, height)
+ writeChartFrame(&b, title, width, height)
+ writeTimelineIdleSpans(&b, layout, start, end, timeline)
+ writeVerticalGrid(&b, layout, times, pointCount, 8)
+ writeHorizontalGrid(&b, layout, scales[0])
+ writeTimelineBoundaries(&b, layout, start, end, timeline)
+ writePlotBorder(&b, layout)
+
+ for i, axisLineX := range axisX {
+ fmt.Fprintf(&b, ``+"\n",
+ axisLineX, layout.PlotTop, axisLineX, layout.PlotBottom, series[i].Color)
+ fmt.Fprintf(&b, `%s`+"\n",
+ axisLineX, 64, series[i].Color, sanitizeChartText(series[i].AxisTitle))
+ for _, tick := range scales[i].Ticks {
+ y := chartYForValue(valueClamp(tick, scales[i]), scales[i], layout.PlotTop, layout.PlotBottom)
+ label := sanitizeChartText(chartYAxisNumber(tick))
+ if i < 2 {
+ fmt.Fprintf(&b, ``+"\n",
+ axisLineX, y, axisLineX+6, y, series[i].Color)
+ fmt.Fprintf(&b, `%s`+"\n",
+ axisLineX-8, y, series[i].Color, label)
+ continue
+ }
+ fmt.Fprintf(&b, ``+"\n",
+ axisLineX, y, axisLineX-6, y, series[i].Color)
+ fmt.Fprintf(&b, `%s`+"\n",
+ axisLineX+8, y, series[i].Color, label)
+ }
+ }
+
+ writeXAxisLabels(&b, layout, times, labels, start, end, 8)
+ for i := range series {
+ writeSeriesPolyline(&b, layout, times, start, end, series[i].Values, scales[i], series[i].Color)
+ }
+ writeLegend(&b, layout, series)
+ writeSVGClose(&b)
+ return []byte(b.String()), nil
+}
+
+func metricsTimelineSegments(samples []platform.LiveMetricSample, now time.Time) []chartTimelineSegment {
+ if len(samples) == 0 {
+ return nil
+ }
+ times := sampleTimes(samples)
+ start, end := chartTimeBounds(times)
+ if start.IsZero() || end.IsZero() {
+ return nil
+ }
+ return chartTimelineSegmentsForRange(start, end, now, snapshotTaskHistory())
+}
+
+func snapshotTaskHistory() []Task {
+ globalQueue.mu.Lock()
+ defer globalQueue.mu.Unlock()
+ out := make([]Task, len(globalQueue.tasks))
+ for i, t := range globalQueue.tasks {
+ out[i] = *t
+ }
+ return out
+}
+
+func chartTimelineSegmentsForRange(start, end, now time.Time, tasks []Task) []chartTimelineSegment {
+ if start.IsZero() || end.IsZero() {
+ return nil
+ }
+ if end.Before(start) {
+ start, end = end, start
+ }
+ type interval struct {
+ start time.Time
+ end time.Time
+ }
+ active := make([]interval, 0, len(tasks))
+ for _, task := range tasks {
+ if task.StartedAt == nil {
+ continue
+ }
+ intervalStart := task.StartedAt.UTC()
+ intervalEnd := now.UTC()
+ if task.DoneAt != nil {
+ intervalEnd = task.DoneAt.UTC()
+ }
+ if !intervalEnd.After(intervalStart) {
+ continue
+ }
+ if intervalEnd.Before(start) || intervalStart.After(end) {
+ continue
+ }
+ if intervalStart.Before(start) {
+ intervalStart = start
+ }
+ if intervalEnd.After(end) {
+ intervalEnd = end
+ }
+ active = append(active, interval{start: intervalStart, end: intervalEnd})
+ }
+ sort.Slice(active, func(i, j int) bool {
+ if active[i].start.Equal(active[j].start) {
+ return active[i].end.Before(active[j].end)
+ }
+ return active[i].start.Before(active[j].start)
+ })
+ merged := make([]interval, 0, len(active))
+ for _, span := range active {
+ if len(merged) == 0 {
+ merged = append(merged, span)
+ continue
+ }
+ last := &merged[len(merged)-1]
+ if !span.start.After(last.end) {
+ if span.end.After(last.end) {
+ last.end = span.end
+ }
+ continue
+ }
+ merged = append(merged, span)
+ }
+
+ segments := make([]chartTimelineSegment, 0, len(merged)*2+1)
+ cursor := start
+ for _, span := range merged {
+ if span.start.After(cursor) {
+ segments = append(segments, chartTimelineSegment{Start: cursor, End: span.start, Active: false})
+ }
+ segments = append(segments, chartTimelineSegment{Start: span.start, End: span.end, Active: true})
+ cursor = span.end
+ }
+ if cursor.Before(end) {
+ segments = append(segments, chartTimelineSegment{Start: cursor, End: end, Active: false})
+ }
+ if len(segments) == 0 {
+ segments = append(segments, chartTimelineSegment{Start: start, End: end, Active: false})
+ }
+ return segments
+}
+
+func sampleTimes(samples []platform.LiveMetricSample) []time.Time {
+ times := make([]time.Time, 0, len(samples))
+ for _, sample := range samples {
+ times = append(times, sample.Timestamp)
+ }
+ return times
+}
+
+func singleAxisChartScale(datasets [][]float64, yMin, yMax *float64) chartScale {
+ min, max := 0.0, 1.0
+ if yMin != nil && yMax != nil {
+ min, max = *yMin, *yMax
+ } else {
+ min, max = chartSeriesBounds(flattenDatasets(datasets))
+ if yMin != nil {
+ min = *yMin
+ }
+ if yMax != nil {
+ max = *yMax
+ }
+ }
+ ticks := chartNiceTicks(min, max, 8)
+ return chartScale{Min: ticks[0], Max: ticks[len(ticks)-1], Ticks: ticks}
+}
+
+func flattenDatasets(datasets [][]float64) []float64 {
+ total := 0
+ for _, ds := range datasets {
+ total += len(ds)
+ }
+ out := make([]float64, 0, total)
+ for _, ds := range datasets {
+ out = append(out, ds...)
+ }
+ return out
+}
+
+func singleAxisChartLayout(canvasHeight int, seriesCount int) chartLayout {
+ legendRows := 0
+ if chartLegendVisible(seriesCount) && seriesCount > 0 {
+ cols := 4
+ if seriesCount < cols {
+ cols = seriesCount
+ }
+ legendRows = (seriesCount + cols - 1) / cols
+ }
+ legendHeight := 0
+ if legendRows > 0 {
+ legendHeight = legendRows*24 + 24
+ }
+ return chartLayout{
+ Width: 1400,
+ Height: canvasHeight,
+ PlotLeft: 96,
+ PlotRight: 1352,
+ PlotTop: 72,
+ PlotBottom: canvasHeight - 60 - legendHeight,
+ }
+}
+
+func chartTimeBounds(times []time.Time) (time.Time, time.Time) {
+ if len(times) == 0 {
+ return time.Time{}, time.Time{}
+ }
+ start := times[0].UTC()
+ end := start
+ for _, ts := range times[1:] {
+ t := ts.UTC()
+ if t.Before(start) {
+ start = t
+ }
+ if t.After(end) {
+ end = t
+ }
+ }
+ return start, end
+}
+
+func synthesizeChartTimes(times []time.Time, count int) []time.Time {
+ if count <= 0 {
+ return nil
+ }
+ if len(times) == count {
+ return times
+ }
+ if len(times) == 1 {
+ out := make([]time.Time, count)
+ for i := range out {
+ out[i] = times[0].Add(time.Duration(i) * time.Minute)
+ }
+ return out
+ }
+ base := time.Now().UTC().Add(-time.Duration(count-1) * time.Minute)
+ out := make([]time.Time, count)
+ for i := range out {
+ out[i] = base.Add(time.Duration(i) * time.Minute)
+ }
+ return out
+}
+
+func writeSVGOpen(b *strings.Builder, width, height int) {
+ fmt.Fprintf(b, `\n")
+}
+
+func writeChartFrame(b *strings.Builder, title string, width, height int) {
+ fmt.Fprintf(b, ``+"\n", width, height)
+ fmt.Fprintf(b, `%s`+"\n",
+ width/2, sanitizeChartText(title))
+}
+
+func writePlotBorder(b *strings.Builder, layout chartLayout) {
+ fmt.Fprintf(b, ``+"\n",
+ layout.PlotLeft, layout.PlotTop, layout.PlotRight-layout.PlotLeft, layout.PlotBottom-layout.PlotTop)
+}
+
+func writeHorizontalGrid(b *strings.Builder, layout chartLayout, scale chartScale) {
+ b.WriteString(`` + "\n")
+ for _, tick := range scale.Ticks {
+ y := chartYForValue(tick, scale, layout.PlotTop, layout.PlotBottom)
+ fmt.Fprintf(b, ``+"\n",
+ layout.PlotLeft, y, layout.PlotRight, y)
+ }
+ b.WriteString(`` + "\n")
+}
+
+func writeVerticalGrid(b *strings.Builder, layout chartLayout, times []time.Time, pointCount, target int) {
+ if pointCount <= 0 {
+ return
+ }
+ start, end := chartTimeBounds(times)
+ b.WriteString(`` + "\n")
+ for _, idx := range gpuChartLabelIndices(pointCount, target) {
+ ts := chartPointTime(times, idx)
+ x := chartXForTime(ts, start, end, layout.PlotLeft, layout.PlotRight)
+ fmt.Fprintf(b, ``+"\n",
+ x, layout.PlotTop, x, layout.PlotBottom)
+ }
+ b.WriteString(`` + "\n")
+}
+
+func writeSingleAxisY(b *strings.Builder, layout chartLayout, scale chartScale) {
+ fmt.Fprintf(b, ``+"\n",
+ layout.PlotLeft, layout.PlotTop, layout.PlotLeft, layout.PlotBottom)
+ for _, tick := range scale.Ticks {
+ y := chartYForValue(tick, scale, layout.PlotTop, layout.PlotBottom)
+ fmt.Fprintf(b, ``+"\n",
+ layout.PlotLeft, y, layout.PlotLeft-6, y)
+ fmt.Fprintf(b, `%s`+"\n",
+ layout.PlotLeft-10, y, sanitizeChartText(chartYAxisNumber(tick)))
+ }
+}
+
+func writeXAxisLabels(b *strings.Builder, layout chartLayout, times []time.Time, labels []string, start, end time.Time, target int) {
+ pointCount := len(labels)
+ if len(times) > pointCount {
+ pointCount = len(times)
+ }
+ b.WriteString(`` + "\n")
+ for _, idx := range gpuChartLabelIndices(pointCount, target) {
+ x := chartXForTime(chartPointTime(times, idx), start, end, layout.PlotLeft, layout.PlotRight)
+ label := ""
+ if idx < len(labels) {
+ label = labels[idx]
+ }
+ fmt.Fprintf(b, `%s`+"\n", x, layout.PlotBottom+28, sanitizeChartText(label))
+ }
+ b.WriteString(`` + "\n")
+ fmt.Fprintf(b, `Time`+"\n",
+ (layout.PlotLeft+layout.PlotRight)/2, layout.PlotBottom+48)
+}
+
+func writeSeriesPolyline(b *strings.Builder, layout chartLayout, times []time.Time, start, end time.Time, values []float64, scale chartScale, color string) {
+ if len(values) == 0 {
+ return
+ }
+ var points strings.Builder
+ for idx, value := range values {
+ if idx > 0 {
+ points.WriteByte(' ')
+ }
+ x := chartXForTime(chartPointTime(times, idx), start, end, layout.PlotLeft, layout.PlotRight)
+ y := chartYForValue(value, scale, layout.PlotTop, layout.PlotBottom)
+ points.WriteString(strconv.FormatFloat(x, 'f', 1, 64))
+ points.WriteByte(',')
+ points.WriteString(strconv.FormatFloat(y, 'f', 1, 64))
+ }
+ fmt.Fprintf(b, ``+"\n",
+ points.String(), color)
+ if len(values) == 1 {
+ x := chartXForTime(chartPointTime(times, 0), start, end, layout.PlotLeft, layout.PlotRight)
+ y := chartYForValue(values[0], scale, layout.PlotTop, layout.PlotBottom)
+ fmt.Fprintf(b, ``+"\n", x, y, color)
+ }
+}
+
+func writeLegend(b *strings.Builder, layout chartLayout, series []metricChartSeries) {
+ if !chartLegendVisible(len(series)) || len(series) == 0 {
+ return
+ }
+ cols := 4
+ if len(series) < cols {
+ cols = len(series)
+ }
+ cellWidth := float64(layout.PlotRight-layout.PlotLeft) / float64(cols)
+ baseY := layout.PlotBottom + 74
+ for i, item := range series {
+ row := i / cols
+ col := i % cols
+ x := float64(layout.PlotLeft) + cellWidth*float64(col) + 8
+ y := float64(baseY + row*24)
+ fmt.Fprintf(b, ``+"\n",
+ x, y, x+28, y, item.Color)
+ fmt.Fprintf(b, `%s`+"\n",
+ x+38, y+4, sanitizeChartText(item.Name))
+ }
+}
+
+func writeTimelineIdleSpans(b *strings.Builder, layout chartLayout, start, end time.Time, segments []chartTimelineSegment) {
+ if len(segments) == 0 {
+ return
+ }
+ b.WriteString(`` + "\n")
+ for _, segment := range segments {
+ if segment.Active || !segment.End.After(segment.Start) {
+ continue
+ }
+ x0 := chartXForTime(segment.Start, start, end, layout.PlotLeft, layout.PlotRight)
+ x1 := chartXForTime(segment.End, start, end, layout.PlotLeft, layout.PlotRight)
+ fmt.Fprintf(b, ``+"\n",
+ x0, layout.PlotTop, math.Max(1, x1-x0), layout.PlotBottom-layout.PlotTop)
+ }
+ b.WriteString(`` + "\n")
+}
+
+func writeTimelineBoundaries(b *strings.Builder, layout chartLayout, start, end time.Time, segments []chartTimelineSegment) {
+ if len(segments) == 0 {
+ return
+ }
+ seen := map[int]bool{}
+ b.WriteString(`` + "\n")
+ for i, segment := range segments {
+ if i > 0 {
+ x := int(math.Round(chartXForTime(segment.Start, start, end, layout.PlotLeft, layout.PlotRight)))
+ if !seen[x] {
+ seen[x] = true
+ fmt.Fprintf(b, ``+"\n", x, layout.PlotTop, x, layout.PlotBottom)
+ }
+ }
+ if i < len(segments)-1 {
+ x := int(math.Round(chartXForTime(segment.End, start, end, layout.PlotLeft, layout.PlotRight)))
+ if !seen[x] {
+ seen[x] = true
+ fmt.Fprintf(b, ``+"\n", x, layout.PlotTop, x, layout.PlotBottom)
+ }
+ }
+ }
+ b.WriteString(`` + "\n")
+}
+
+func chartXForTime(ts, start, end time.Time, left, right int) float64 {
+ if !end.After(start) {
+ return float64(left+right) / 2
+ }
+ if ts.Before(start) {
+ ts = start
+ }
+ if ts.After(end) {
+ ts = end
+ }
+ ratio := float64(ts.Sub(start)) / float64(end.Sub(start))
+ return float64(left) + ratio*float64(right-left)
+}
+
+func chartPointTime(times []time.Time, idx int) time.Time {
+ if idx >= 0 && idx < len(times) && !times[idx].IsZero() {
+ return times[idx].UTC()
+ }
+ if len(times) > 0 && !times[0].IsZero() {
+ return times[0].UTC().Add(time.Duration(idx) * time.Minute)
+ }
+ return time.Now().UTC().Add(time.Duration(idx) * time.Minute)
+}
+
+func chartYForValue(value float64, scale chartScale, plotTop, plotBottom int) float64 {
+ if scale.Max <= scale.Min {
+ return float64(plotTop+plotBottom) / 2
+ }
+ return float64(plotBottom) - (value-scale.Min)/(scale.Max-scale.Min)*float64(plotBottom-plotTop)
+}
+
+func chartSeriesBounds(values []float64) (float64, float64) {
+ if len(values) == 0 {
+ return 0, 1
+ }
+ min, max := values[0], values[0]
+ for _, value := range values[1:] {
+ if value < min {
+ min = value
+ }
+ if value > max {
+ max = value
+ }
+ }
+ if min == max {
+ if max == 0 {
+ return 0, 1
+ }
+ pad := math.Abs(max) * 0.1
+ if pad == 0 {
+ pad = 1
+ }
+ min -= pad
+ max += pad
+ }
+ if min > 0 {
+ pad := (max - min) * 0.2
+ if pad == 0 {
+ pad = max * 0.1
+ }
+ min -= pad
+ if min < 0 {
+ min = 0
+ }
+ max += pad
+ }
+ return min, max
+}
+
+func chartNiceTicks(min, max float64, target int) []float64 {
+ if min == max {
+ max = min + 1
+ }
+ span := max - min
+ step := math.Pow(10, math.Floor(math.Log10(span/float64(target))))
+ for _, factor := range []float64{1, 2, 5, 10} {
+ if span/(factor*step) <= float64(target)*1.5 {
+ step = factor * step
+ break
+ }
+ }
+ low := math.Floor(min/step) * step
+ high := math.Ceil(max/step) * step
+ var ticks []float64
+ for value := low; value <= high+step*0.001; value += step {
+ ticks = append(ticks, math.Round(value*1e9)/1e9)
+ }
+ return ticks
+}
+
+func valueClamp(value float64, scale chartScale) float64 {
+ if value < scale.Min {
+ return scale.Min
+ }
+ if value > scale.Max {
+ return scale.Max
+ }
+ return value
+}
diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go
index 7e1b93f..d0c83d8 100644
--- a/audit/internal/webui/server.go
+++ b/audit/internal/webui/server.go
@@ -8,7 +8,6 @@ import (
"html"
"io"
"log/slog"
- "math"
"mime"
"net"
"net/http"
@@ -16,7 +15,6 @@ import (
"path/filepath"
"runtime/debug"
"sort"
- "strconv"
"strings"
"sync"
"time"
@@ -24,7 +22,6 @@ import (
"bee/audit/internal/app"
"bee/audit/internal/platform"
"bee/audit/internal/runtimeenv"
- gocharts "github.com/go-analyze/charts"
"reanimator/chart/viewer"
"reanimator/chart/web"
)
@@ -557,13 +554,14 @@ func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request)
http.Error(w, "metrics database not available", http.StatusServiceUnavailable)
return
}
+ samples, err := h.metricsDB.LoadAll()
+ if err != nil || len(samples) == 0 {
+ http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ timeline := metricsTimelineSegments(samples, time.Now())
if idx, sub, ok := parseGPUChartPath(path); ok && sub == "overview" {
- samples, err := h.metricsDB.LoadAll()
- if err != nil || len(samples) == 0 {
- http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable)
- return
- }
- buf, ok, err := renderGPUOverviewChartSVG(idx, samples)
+ buf, ok, err := renderGPUOverviewChartSVG(idx, samples, timeline)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -577,13 +575,23 @@ func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request)
_, _ = w.Write(buf)
return
}
- datasets, names, labels, title, yMin, yMax, ok := h.chartDataFromDB(path)
+ datasets, names, labels, title, yMin, yMax, ok := chartDataFromSamples(path, samples)
if !ok {
http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable)
return
}
- buf, err := renderChartSVGWithHeight(title, datasets, names, labels, yMin, yMax, chartCanvasHeightForPath(path, len(names)))
+ buf, err := renderMetricChartSVG(
+ title,
+ labels,
+ sampleTimes(samples),
+ datasets,
+ names,
+ yMin,
+ yMax,
+ chartCanvasHeightForPath(path, len(names)),
+ timeline,
+ )
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -593,14 +601,6 @@ func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request)
_, _ = w.Write(buf)
}
-func (h *handler) chartDataFromDB(path string) ([][]float64, []string, []string, string, *float64, *float64, bool) {
- samples, err := h.metricsDB.LoadAll()
- if err != nil || len(samples) == 0 {
- return nil, nil, nil, "", nil, nil, false
- }
- return chartDataFromSamples(path, samples)
-}
-
func chartDataFromSamples(path string, samples []platform.LiveMetricSample) ([][]float64, []string, []string, string, *float64, *float64, bool) {
var datasets [][]float64
var names []string
@@ -998,247 +998,6 @@ func autoBounds120(datasets ...[]float64) (*float64, *float64) {
return floatPtr(low), floatPtr(high)
}
-func renderGPUOverviewChartSVG(idx int, samples []platform.LiveMetricSample) ([]byte, bool, error) {
- temp := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.TempC })
- power := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.PowerW })
- coreClock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.ClockMHz })
- memClock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.MemClockMHz })
- if temp == nil && power == nil && coreClock == nil && memClock == nil {
- return nil, false, nil
- }
- labels := sampleTimeLabels(samples)
- svg, err := drawGPUOverviewChartSVG(
- fmt.Sprintf("GPU %d Overview", idx),
- labels,
- []gpuOverviewSeries{
- {Name: "Temp C", Values: coalesceDataset(temp, len(samples)), Color: "#f05a5a", AxisTitle: "Temp C"},
- {Name: "Power W", Values: coalesceDataset(power, len(samples)), Color: "#ffb357", AxisTitle: "Power W"},
- {Name: "Core Clock MHz", Values: coalesceDataset(coreClock, len(samples)), Color: "#73bf69", AxisTitle: "Core MHz"},
- {Name: "Memory Clock MHz", Values: coalesceDataset(memClock, len(samples)), Color: "#5794f2", AxisTitle: "Memory MHz"},
- },
- )
- if err != nil {
- return nil, false, err
- }
- return svg, true, nil
-}
-
-type gpuOverviewSeries struct {
- Name string
- AxisTitle string
- Color string
- Values []float64
-}
-
-func drawGPUOverviewChartSVG(title string, labels []string, series []gpuOverviewSeries) ([]byte, error) {
- if len(series) != 4 {
- return nil, fmt.Errorf("gpu overview requires 4 series, got %d", len(series))
- }
- const (
- width = 1400
- height = 840
- plotLeft = 180
- plotRight = 1220
- plotTop = 96
- plotBottom = 602
- )
- const (
- leftOuterAxis = 72
- leftInnerAxis = 132
- rightInnerAxis = 1268
- rightOuterAxis = 1328
- )
- axisX := []int{leftOuterAxis, leftInnerAxis, rightInnerAxis, rightOuterAxis}
- plotWidth := plotRight - plotLeft
- plotHeight := plotBottom - plotTop
-
- pointCount := len(labels)
- if pointCount == 0 {
- pointCount = 1
- labels = []string{""}
- }
- for i := range series {
- if len(series[i].Values) == 0 {
- series[i].Values = make([]float64, pointCount)
- }
- }
-
- type axisScale struct {
- Min float64
- Max float64
- Ticks []float64
- }
- scales := make([]axisScale, len(series))
- for i := range series {
- min, max := gpuChartSeriesBounds(series[i].Values)
- ticks := gpuChartNiceTicks(min, max, 8)
- scales[i] = axisScale{
- Min: ticks[0],
- Max: ticks[len(ticks)-1],
- Ticks: ticks,
- }
- }
-
- xFor := func(index int) float64 {
- if pointCount <= 1 {
- return float64(plotLeft + plotWidth/2)
- }
- return float64(plotLeft) + float64(index)*float64(plotWidth)/float64(pointCount-1)
- }
- yFor := func(value float64, scale axisScale) float64 {
- if scale.Max <= scale.Min {
- return float64(plotTop + plotHeight/2)
- }
- return float64(plotBottom) - (value-scale.Min)/(scale.Max-scale.Min)*float64(plotHeight)
- }
-
- var b strings.Builder
- b.WriteString(fmt.Sprintf(`\n")
- return []byte(b.String()), nil
-}
-
-func gpuChartSeriesBounds(values []float64) (float64, float64) {
- if len(values) == 0 {
- return 0, 1
- }
- min, max := values[0], values[0]
- for _, value := range values[1:] {
- if value < min {
- min = value
- }
- if value > max {
- max = value
- }
- }
- if min == max {
- if max == 0 {
- return 0, 1
- }
- pad := math.Abs(max) * 0.1
- if pad == 0 {
- pad = 1
- }
- min -= pad
- max += pad
- }
- if min > 0 {
- pad := (max - min) * 0.2
- if pad == 0 {
- pad = max * 0.1
- }
- min -= pad
- if min < 0 {
- min = 0
- }
- max += pad
- }
- return min, max
-}
-
-func gpuChartNiceTicks(min, max float64, target int) []float64 {
- if min == max {
- max = min + 1
- }
- span := max - min
- step := math.Pow(10, math.Floor(math.Log10(span/float64(target))))
- for _, factor := range []float64{1, 2, 5, 10} {
- if span/(factor*step) <= float64(target)*1.5 {
- step = factor * step
- break
- }
- }
- low := math.Floor(min/step) * step
- high := math.Ceil(max/step) * step
- var ticks []float64
- for value := low; value <= high+step*0.001; value += step {
- ticks = append(ticks, math.Round(value*1e9)/1e9)
- }
- return ticks
-}
-
-func gpuChartFormatTick(value float64) string {
- if value == math.Trunc(value) {
- return strconv.Itoa(int(value))
- }
- return strconv.FormatFloat(value, 'f', 1, 64)
-}
-
func gpuChartLabelIndices(total, target int) []int {
if total <= 0 {
return nil
@@ -1260,70 +1019,6 @@ func gpuChartLabelIndices(total, target int) []int {
return indices
}
-// renderChartSVG renders a line chart SVG with a fixed Y-axis range.
-func renderChartSVG(title string, datasets [][]float64, names []string, labels []string, yMin, yMax *float64) ([]byte, error) {
- return renderChartSVGWithHeight(title, datasets, names, labels, yMin, yMax, chartCanvasHeight(len(names)))
-}
-
-func renderChartSVGWithHeight(title string, datasets [][]float64, names []string, labels []string, yMin, yMax *float64, canvasHeight int) ([]byte, error) {
- n := len(labels)
- if n == 0 {
- n = 1
- labels = []string{""}
- }
- for i := range datasets {
- if len(datasets[i]) == 0 {
- datasets[i] = make([]float64, n)
- }
- }
- // Append global min/avg/max to title.
- mn, avg, mx := globalStats(datasets)
- if mx > 0 {
- title = fmt.Sprintf("%s ↓%s ~%s ↑%s",
- title,
- chartLegendNumber(mn),
- chartLegendNumber(avg),
- chartLegendNumber(mx),
- )
- }
- title = sanitizeChartText(title)
- names = sanitizeChartTexts(names)
- sparse := sanitizeChartTexts(sparseLabels(labels, 6))
-
- opt := gocharts.NewLineChartOptionWithData(datasets)
- opt.Title = gocharts.TitleOption{Text: title}
- opt.XAxis.Labels = sparse
- opt.Legend = gocharts.LegendOption{SeriesNames: names}
- if chartLegendVisible(len(names)) {
- opt.Legend.Offset = gocharts.OffsetStr{Top: gocharts.PositionBottom}
- opt.Legend.OverlayChart = gocharts.Ptr(false)
- } else {
- opt.Legend.Show = gocharts.Ptr(false)
- }
- opt.Symbol = gocharts.SymbolNone
- // Right padding: reserve space for the MarkLine label (library recommendation).
- opt.Padding = gocharts.NewBox(20, 20, 80, 20)
- if yMin != nil || yMax != nil {
- opt.YAxis = []gocharts.YAxisOption{chartYAxisOption(yMin, yMax)}
- }
-
- // Add a single peak mark line on the series that holds the global maximum.
- peakIdx, _ := globalPeakSeries(datasets)
- if peakIdx >= 0 && peakIdx < len(opt.SeriesList) {
- opt.SeriesList[peakIdx].MarkLine = gocharts.NewMarkLine(gocharts.SeriesMarkTypeMax)
- }
-
- p := gocharts.NewPainter(gocharts.PainterOptions{
- OutputFormat: gocharts.ChartOutputSVG,
- Width: 1400,
- Height: canvasHeight,
- }, gocharts.PainterThemeOption(gocharts.GetTheme("grafana")))
- if err := p.LineChart(opt); err != nil {
- return nil, err
- }
- return p.Bytes()
-}
-
func chartCanvasHeightForPath(path string, seriesCount int) int {
height := chartCanvasHeight(seriesCount)
if isGPUChartPath(path) {
@@ -1347,30 +1042,6 @@ func chartCanvasHeight(seriesCount int) int {
return 288
}
-func chartYAxisOption(yMin, yMax *float64) gocharts.YAxisOption {
- return gocharts.YAxisOption{
- Min: yMin,
- Max: yMax,
- LabelCount: 11,
- ValueFormatter: chartYAxisNumber,
- }
-}
-
-// globalPeakSeries returns the index of the series containing the global maximum
-// value across all datasets, and that maximum value.
-func globalPeakSeries(datasets [][]float64) (idx int, peak float64) {
- idx = -1
- for i, ds := range datasets {
- for _, v := range ds {
- if v > peak {
- peak = v
- idx = i
- }
- }
- }
- return idx, peak
-}
-
// globalStats returns min, average, and max across all values in all datasets.
func globalStats(datasets [][]float64) (mn, avg, mx float64) {
var sum float64
@@ -1410,21 +1081,6 @@ func sanitizeChartText(s string) string {
}, s))
}
-func sanitizeChartTexts(in []string) []string {
- out := make([]string, len(in))
- for i, s := range in {
- out[i] = sanitizeChartText(s)
- }
- return out
-}
-
-func safeIdx(s []float64, i int) float64 {
- if i < len(s) {
- return s[i]
- }
- return 0
-}
-
func snapshotNamedRings(rings []*namedMetricsRing) ([][]float64, []string, []string) {
var datasets [][]float64
var names []string
@@ -1511,20 +1167,6 @@ func chartYAxisNumber(v float64) string {
return out
}
-func sparseLabels(labels []string, n int) []string {
- out := make([]string, len(labels))
- step := len(labels) / n
- if step < 1 {
- step = 1
- }
- for i, l := range labels {
- if i%step == 0 {
- out[i] = l
- }
- }
- return out
-}
-
func (h *handler) handleAPIMetricsExportCSV(w http.ResponseWriter, r *http.Request) {
if h.metricsDB == nil {
http.Error(w, "metrics database not available", http.StatusServiceUnavailable)
diff --git a/audit/internal/webui/server_test.go b/audit/internal/webui/server_test.go
index fbafead..f9aa2ba 100644
--- a/audit/internal/webui/server_test.go
+++ b/audit/internal/webui/server_test.go
@@ -304,6 +304,124 @@ func TestChartCanvasHeight(t *testing.T) {
}
}
+func TestChartTimelineSegmentsForRangeMergesActiveSpansAndIdleGaps(t *testing.T) {
+ start := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
+ end := start.Add(10 * time.Minute)
+ taskWindow := func(offsetStart, offsetEnd time.Duration) Task {
+ s := start.Add(offsetStart)
+ e := start.Add(offsetEnd)
+ return Task{
+ Name: "task",
+ Status: TaskDone,
+ StartedAt: &s,
+ DoneAt: &e,
+ }
+ }
+ segments := chartTimelineSegmentsForRange(start, end, end, []Task{
+ taskWindow(1*time.Minute, 3*time.Minute),
+ taskWindow(2*time.Minute, 5*time.Minute),
+ taskWindow(7*time.Minute, 8*time.Minute),
+ })
+ if len(segments) != 5 {
+ t.Fatalf("segments=%d want 5: %#v", len(segments), segments)
+ }
+ wantActive := []bool{false, true, false, true, false}
+ wantMinutes := [][2]int{{0, 1}, {1, 5}, {5, 7}, {7, 8}, {8, 10}}
+ for i, segment := range segments {
+ if segment.Active != wantActive[i] {
+ t.Fatalf("segment[%d].Active=%v want %v", i, segment.Active, wantActive[i])
+ }
+ if got := int(segment.Start.Sub(start).Minutes()); got != wantMinutes[i][0] {
+ t.Fatalf("segment[%d] start=%d want %d", i, got, wantMinutes[i][0])
+ }
+ if got := int(segment.End.Sub(start).Minutes()); got != wantMinutes[i][1] {
+ t.Fatalf("segment[%d] end=%d want %d", i, got, wantMinutes[i][1])
+ }
+ }
+}
+
+func TestRenderMetricChartSVGIncludesTimelineOverlay(t *testing.T) {
+ start := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
+ labels := []string{"12:00", "12:01", "12:02"}
+ times := []time.Time{start, start.Add(time.Minute), start.Add(2 * time.Minute)}
+ svg, err := renderMetricChartSVG(
+ "System Power",
+ labels,
+ times,
+ [][]float64{{300, 320, 310}},
+ []string{"Power W"},
+ floatPtr(0),
+ floatPtr(400),
+ 360,
+ []chartTimelineSegment{
+ {Start: start, End: start.Add(time.Minute), Active: false},
+ {Start: start.Add(time.Minute), End: start.Add(2 * time.Minute), Active: true},
+ },
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := string(svg)
+ if !strings.Contains(body, `data-role="timeline-overlay"`) {
+ t.Fatalf("svg missing timeline overlay: %s", body)
+ }
+ if !strings.Contains(body, `opacity="0.10"`) {
+ t.Fatalf("svg missing idle overlay opacity: %s", body)
+ }
+ if !strings.Contains(body, `System Power`) {
+ t.Fatalf("svg missing chart title: %s", body)
+ }
+}
+
+func TestHandleMetricsChartSVGRendersCustomSVG(t *testing.T) {
+ dir := t.TempDir()
+ db, err := openMetricsDB(filepath.Join(dir, "metrics.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = db.db.Close() })
+
+ start := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
+ for i, sample := range []platform.LiveMetricSample{
+ {Timestamp: start, PowerW: 300},
+ {Timestamp: start.Add(time.Minute), PowerW: 320},
+ {Timestamp: start.Add(2 * time.Minute), PowerW: 310},
+ } {
+ if err := db.Write(sample); err != nil {
+ t.Fatalf("write sample %d: %v", i, err)
+ }
+ }
+
+ globalQueue.mu.Lock()
+ prevTasks := globalQueue.tasks
+ s := start.Add(30 * time.Second)
+ e := start.Add(90 * time.Second)
+ globalQueue.tasks = []*Task{{Name: "Burn", Status: TaskDone, StartedAt: &s, DoneAt: &e}}
+ globalQueue.mu.Unlock()
+ t.Cleanup(func() {
+ globalQueue.mu.Lock()
+ globalQueue.tasks = prevTasks
+ globalQueue.mu.Unlock()
+ })
+
+ h := &handler{opts: HandlerOptions{ExportDir: dir}, metricsDB: db}
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/metrics/chart/server-power.svg", nil)
+ h.handleMetricsChartSVG(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, `data-role="timeline-overlay"`) {
+ t.Fatalf("custom svg response missing timeline overlay: %s", body)
+ }
+ if !strings.Contains(body, `stroke-linecap="round"`) {
+ t.Fatalf("custom svg response missing custom polyline styling: %s", body)
+ }
+}
+
func TestNormalizeFanSeriesHoldsLastPositive(t *testing.T) {
got := normalizeFanSeries([]float64{4200, 0, 0, 4300, 0})
want := []float64{4200, 4200, 4200, 4300, 4300}
@@ -317,21 +435,6 @@ func TestNormalizeFanSeriesHoldsLastPositive(t *testing.T) {
}
}
-func TestChartYAxisOption(t *testing.T) {
- min := floatPtr(0)
- max := floatPtr(100)
- opt := chartYAxisOption(min, max)
- if opt.Min != min || opt.Max != max {
- t.Fatalf("chartYAxisOption min/max mismatch: %#v", opt)
- }
- if opt.LabelCount != 11 {
- t.Fatalf("chartYAxisOption labelCount=%d want 11", opt.LabelCount)
- }
- if got := opt.ValueFormatter(1000); got != "1к" {
- t.Fatalf("chartYAxisOption formatter(1000)=%q want 1к", got)
- }
-}
-
func TestSnapshotFanRingsUsesTimelineLabels(t *testing.T) {
r1 := newMetricsRing(4)
r2 := newMetricsRing(4)