package webui import ( "fmt" "math" "sort" "strconv" "strings" "sync" "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", } var gpuLabelCache struct { mu sync.Mutex loadedAt time.Time byIndex map[int]string } 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) } } statsLabel := chartStatsLabel(datasets) 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, statsLabel, 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 }) if temp == nil && power == nil && coreClock == nil { return nil, false, nil } labels := sampleTimeLabels(samples) times := sampleTimes(samples) svg, err := drawGPUOverviewChartSVG( gpuDisplayLabel(idx)+" Overview", 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"}, }, 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) != 3 { return nil, fmt.Errorf("gpu overview requires 3 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 ) layout := chartLayout{ Width: width, Height: height, PlotLeft: plotLeft, PlotRight: plotRight, PlotTop: plotTop, PlotBottom: plotBottom, } axisX := []int{leftOuterAxis, leftInnerAxis, rightInnerAxis} 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, subtitle string, width, height int) { fmt.Fprintf(b, ``+"\n", width, height) fmt.Fprintf(b, `%s`+"\n", width/2, sanitizeChartText(title)) if strings.TrimSpace(subtitle) != "" { fmt.Fprintf(b, `%s`+"\n", width/2, sanitizeChartText(subtitle)) } } 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) return } peakIdx := 0 peakValue := values[0] for idx, value := range values[1:] { if value >= peakValue { peakIdx = idx + 1 peakValue = value } } x := chartXForTime(chartPointTime(times, peakIdx), start, end, layout.PlotLeft, layout.PlotRight) y := chartYForValue(peakValue, scale, layout.PlotTop, layout.PlotBottom) fmt.Fprintf(b, ``+"\n", x, y, color) fmt.Fprintf(b, ``+"\n", x, y-10, x-5, y-18, x+5, y-18, 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 } func chartStatsLabel(datasets [][]float64) string { mn, avg, mx := globalStats(datasets) if mx <= 0 && avg <= 0 && mn <= 0 { return "" } return fmt.Sprintf("min %s avg %s max %s", chartLegendNumber(mn), chartLegendNumber(avg), chartLegendNumber(mx), ) } func gpuDisplayLabel(idx int) string { if name := gpuModelNameByIndex(idx); name != "" { return fmt.Sprintf("GPU %d — %s", idx, name) } return fmt.Sprintf("GPU %d", idx) } func gpuModelNameByIndex(idx int) string { now := time.Now() gpuLabelCache.mu.Lock() if now.Sub(gpuLabelCache.loadedAt) > 30*time.Second || gpuLabelCache.byIndex == nil { gpuLabelCache.loadedAt = now gpuLabelCache.byIndex = loadGPUModelNames() } name := strings.TrimSpace(gpuLabelCache.byIndex[idx]) gpuLabelCache.mu.Unlock() return name } func loadGPUModelNames() map[int]string { out := map[int]string{} gpus, err := platform.New().ListNvidiaGPUs() if err != nil { return out } for _, gpu := range gpus { name := strings.TrimSpace(gpu.Name) if name != "" { out[gpu.Index] = name } } return out }