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", width, height, width, height) +} + +func writeSVGClose(b *strings.Builder) { + b.WriteString("\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(``, width, height, width, height)) - b.WriteString("\n") - b.WriteString(`` + "\n") - b.WriteString(`` + sanitizeChartText(title) + `` + "\n") - - b.WriteString(`` + "\n") - for _, tick := range scales[0].Ticks { - y := yFor(tick, scales[0]) - fmt.Fprintf(&b, ``+"\n", plotLeft, y, plotRight, y) - } - for _, idx := range gpuChartLabelIndices(pointCount, 8) { - x := xFor(idx) - fmt.Fprintf(&b, ``+"\n", x, plotTop, x, plotBottom) - } - b.WriteString("\n") - - fmt.Fprintf(&b, ``+"\n", - plotLeft, plotTop, plotWidth, plotHeight) - - for i, axisLineX := range axisX { - fmt.Fprintf(&b, ``+"\n", - axisLineX, plotTop, axisLineX, 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 := yFor(tick, scales[i]) - label := sanitizeChartText(gpuChartFormatTick(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) - } - } - - b.WriteString(`` + "\n") - for _, idx := range gpuChartLabelIndices(pointCount, 8) { - x := xFor(idx) - fmt.Fprintf(&b, `%s`+"\n", x, plotBottom+28, sanitizeChartText(labels[idx])) - } - b.WriteString(`` + "\n") - b.WriteString(`Time` + "\n") - - for i := range series { - var points strings.Builder - for j, value := range series[i].Values { - if j > 0 { - points.WriteByte(' ') - } - points.WriteString(strconv.FormatFloat(xFor(j), 'f', 1, 64)) - points.WriteByte(',') - points.WriteString(strconv.FormatFloat(yFor(value, scales[i]), 'f', 1, 64)) - } - fmt.Fprintf(&b, ``+"\n", - points.String(), series[i].Color) - if len(series[i].Values) == 1 { - fmt.Fprintf(&b, ``+"\n", - xFor(0), yFor(series[i].Values[0], scales[i]), series[i].Color) - } - } - - const legendY = 724 - legendX := []int{190, 470, 790, 1090} - for i := range series { - fmt.Fprintf(&b, ``+"\n", - legendX[i], legendY, legendX[i]+28, legendY, series[i].Color) - fmt.Fprintf(&b, `%s`+"\n", - legendX[i]+38, legendY+4, sanitizeChartText(series[i].Name)) - } - - b.WriteString("\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)