package webui import ( "fmt" "html" "net/http" "sort" "strings" "time" "bee/audit/internal/platform" ) func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/metrics/chart/") path = strings.TrimSuffix(path, ".svg") if h.metricsDB == nil { 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" { var overviewOk bool var buf []byte buf, overviewOk, err = renderGPUOverviewChartSVG(idx, samples, timeline) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if !overviewOk { http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(buf) return } datasets, names, labels, title, yMin, yMax, stacked, ok := chartDataFromSamples(path, samples) if !ok { http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable) return } var buf []byte if stacked { buf, err = renderStackedMetricChartSVG( title, labels, sampleTimes(samples), datasets, names, yMax, chartCanvasHeightForPath(path, len(names)), timeline, ) } else { 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 } w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(buf) } func chartDataFromSamples(path string, samples []platform.LiveMetricSample) (datasets [][]float64, names []string, labels []string, title string, yMin, yMax *float64, stacked bool, ok bool) { labels = sampleTimeLabels(samples) switch { case path == "server-load": title = "CPU / Memory Load" cpu := make([]float64, len(samples)) mem := make([]float64, len(samples)) for i, s := range samples { cpu[i] = s.CPULoadPct mem[i] = s.MemLoadPct } datasets = [][]float64{cpu, mem} names = []string{"CPU Load %", "Mem Load %"} yMin = floatPtr(0) yMax = floatPtr(100) case path == "server-temp", path == "server-temp-cpu": title = "CPU Temperature" datasets, names = namedTempDatasets(samples, "cpu") yMin = floatPtr(0) yMax = autoMax120(datasets...) case path == "server-temp-gpu": title = "GPU Temperature" datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.TempC }) yMin = floatPtr(0) yMax = autoMax120(datasets...) case path == "server-temp-ambient": title = "Ambient / Other Sensors" datasets, names = namedTempDatasets(samples, "ambient") yMin = floatPtr(0) yMax = autoMax120(datasets...) case path == "server-power": title = "System Power" power := make([]float64, len(samples)) label := "Power W" for i, s := range samples { power[i] = s.PowerW if strings.TrimSpace(s.PowerSource) != "" { label = fmt.Sprintf("Power W · %s", s.PowerSource) if strings.TrimSpace(s.PowerMode) != "" { label += fmt.Sprintf(" (%s)", s.PowerMode) } } } power = normalizePowerSeries(power) datasets = [][]float64{power} names = []string{label} yMin = floatPtr(0) yMax = autoMax120(power) case path == "server-fans": title = "Fan RPM" datasets, names = namedFanDatasets(samples) yMin, yMax = autoBounds120(datasets...) case path == "gpu-all-load": title = "GPU Compute Load" datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.UsagePct }) yMin = floatPtr(0) yMax = floatPtr(100) case path == "gpu-all-memload": title = "GPU Memory Load" datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.MemUsagePct }) yMin = floatPtr(0) yMax = floatPtr(100) case path == "gpu-all-power": title = "GPU Power" datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.PowerW }) yMin, yMax = autoBounds120(datasets...) case path == "gpu-all-temp": title = "GPU Temperature" datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.TempC }) yMin = floatPtr(0) yMax = autoMax120(datasets...) case path == "gpu-all-clock": title = "GPU Core Clock" datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.ClockMHz }) yMin, yMax = autoBounds120(datasets...) case path == "gpu-all-memclock": title = "GPU Memory Clock" datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.MemClockMHz }) yMin, yMax = autoBounds120(datasets...) case strings.HasPrefix(path, "gpu/"): idx, sub, ok := parseGPUChartPath(path) if !ok { return nil, nil, nil, "", nil, nil, false, false } switch sub { case "load": title = gpuDisplayLabel(idx) + " Load" util := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.UsagePct }) mem := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.MemUsagePct }) if util == nil && mem == nil { return nil, nil, nil, "", nil, nil, false, false } datasets = [][]float64{coalesceDataset(util, len(samples)), coalesceDataset(mem, len(samples))} names = []string{"Load %", "Mem %"} yMin = floatPtr(0) yMax = floatPtr(100) case "temp": title = gpuDisplayLabel(idx) + " Temperature" temp := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.TempC }) if temp == nil { return nil, nil, nil, "", nil, nil, false, false } datasets = [][]float64{temp} names = []string{"Temp °C"} yMin = floatPtr(0) yMax = autoMax120(temp) case "clock": title = gpuDisplayLabel(idx) + " Core Clock" clock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.ClockMHz }) if clock == nil { return nil, nil, nil, "", nil, nil, false, false } datasets = [][]float64{clock} names = []string{"Core Clock MHz"} yMin, yMax = autoBounds120(clock) case "memclock": title = gpuDisplayLabel(idx) + " Memory Clock" clock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.MemClockMHz }) if clock == nil { return nil, nil, nil, "", nil, nil, false, false } datasets = [][]float64{clock} names = []string{"Memory Clock MHz"} yMin, yMax = autoBounds120(clock) default: title = gpuDisplayLabel(idx) + " Power" power := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.PowerW }) if power == nil { return nil, nil, nil, "", nil, nil, false, false } datasets = [][]float64{power} names = []string{"Power W"} yMin, yMax = autoBounds120(power) } default: return nil, nil, nil, "", nil, nil, false, false } return datasets, names, labels, title, yMin, yMax, stacked, len(datasets) > 0 } func parseGPUChartPath(path string) (idx int, sub string, ok bool) { if !strings.HasPrefix(path, "gpu/") { return 0, "", false } rest := strings.TrimPrefix(path, "gpu/") if rest == "" { return 0, "", false } sub = "" if i := strings.LastIndex(rest, "-"); i > 0 { sub = rest[i+1:] rest = rest[:i] } n, err := fmt.Sscanf(rest, "%d", &idx) if err != nil || n != 1 { return 0, "", false } return idx, sub, true } func sampleTimeLabels(samples []platform.LiveMetricSample) []string { labels := make([]string, len(samples)) if len(samples) == 0 { return labels } times := make([]time.Time, len(samples)) for i, s := range samples { times[i] = s.Timestamp } sameDay := timestampsSameLocalDay(times) for i, s := range samples { labels[i] = formatTimelineLabel(s.Timestamp.Local(), sameDay) } return labels } func namedTempDatasets(samples []platform.LiveMetricSample, group string) ([][]float64, []string) { seen := map[string]bool{} var names []string for _, s := range samples { for _, t := range s.Temps { if t.Group == group && !seen[t.Name] { seen[t.Name] = true names = append(names, t.Name) } } } sort.Strings(names) datasets := make([][]float64, 0, len(names)) for _, name := range names { ds := make([]float64, len(samples)) for i, s := range samples { for _, t := range s.Temps { if t.Group == group && t.Name == name { ds[i] = t.Celsius break } } } datasets = append(datasets, ds) } return datasets, names } func namedFanDatasets(samples []platform.LiveMetricSample) ([][]float64, []string) { seen := map[string]bool{} var names []string for _, s := range samples { for _, f := range s.Fans { if !seen[f.Name] { seen[f.Name] = true names = append(names, f.Name) } } } sort.Strings(names) datasets := make([][]float64, 0, len(names)) for _, name := range names { ds := make([]float64, len(samples)) for i, s := range samples { for _, f := range s.Fans { if f.Name == name { ds[i] = f.RPM break } } } datasets = append(datasets, normalizeFanSeries(ds)) } return datasets, names } func gpuDatasets(samples []platform.LiveMetricSample, pick func(platform.GPUMetricRow) float64) ([][]float64, []string) { seen := map[int]bool{} var indices []int for _, s := range samples { for _, g := range s.GPUs { if !seen[g.GPUIndex] { seen[g.GPUIndex] = true indices = append(indices, g.GPUIndex) } } } sort.Ints(indices) datasets := make([][]float64, 0, len(indices)) names := make([]string, 0, len(indices)) for _, idx := range indices { ds := gpuDatasetByIndex(samples, idx, pick) if ds == nil { continue } datasets = append(datasets, ds) names = append(names, gpuDisplayLabel(idx)) } return datasets, names } func gpuDatasetByIndex(samples []platform.LiveMetricSample, idx int, pick func(platform.GPUMetricRow) float64) []float64 { found := false ds := make([]float64, len(samples)) for i, s := range samples { for _, g := range s.GPUs { if g.GPUIndex == idx { ds[i] = pick(g) found = true break } } } if !found { return nil } return ds } func coalesceDataset(ds []float64, n int) []float64 { if ds != nil { return ds } return make([]float64, n) } func normalizePowerSeries(ds []float64) []float64 { if len(ds) == 0 { return nil } out := make([]float64, len(ds)) copy(out, ds) last := 0.0 haveLast := false for i, v := range out { if v > 0 { last = v haveLast = true continue } if haveLast { out[i] = last } } return out } func normalizeFanSeries(ds []float64) []float64 { if len(ds) == 0 { return nil } out := make([]float64, len(ds)) var lastPositive float64 for i, v := range ds { if v > 0 { lastPositive = v out[i] = v continue } if lastPositive > 0 { out[i] = lastPositive continue } out[i] = 0 } return out } // floatPtr returns a pointer to a float64 value. func floatPtr(v float64) *float64 { return &v } // autoMax120 returns 0→max+20% Y-axis max across all datasets. func autoMax120(datasets ...[]float64) *float64 { max := 0.0 for _, ds := range datasets { for _, v := range ds { if v > max { max = v } } } if max == 0 { return nil // let library auto-scale } v := max * 1.2 return &v } func autoBounds120(datasets ...[]float64) (*float64, *float64) { min := 0.0 max := 0.0 first := true for _, ds := range datasets { for _, v := range ds { if first { min, max = v, v first = false continue } if v < min { min = v } if v > max { max = v } } } if first { return nil, nil } if max <= 0 { return floatPtr(0), nil } span := max - min if span <= 0 { span = max * 0.1 if span <= 0 { span = 1 } } pad := span * 0.2 low := min - pad if low < 0 { low = 0 } high := max + pad return floatPtr(low), floatPtr(high) } func gpuChartLabelIndices(total, target int) []int { if total <= 0 { return nil } if total == 1 { return []int{0} } step := total / target if step < 1 { step = 1 } var indices []int for i := 0; i < total; i += step { indices = append(indices, i) } if indices[len(indices)-1] != total-1 { indices = append(indices, total-1) } return indices } func chartCanvasHeightForPath(path string, seriesCount int) int { height := chartCanvasHeight(seriesCount) if isGPUChartPath(path) { return height * 2 } return height } func isGPUChartPath(path string) bool { return strings.HasPrefix(path, "gpu-all-") || strings.HasPrefix(path, "gpu/") } func chartLegendVisible(seriesCount int) bool { return seriesCount <= 8 } func chartCanvasHeight(seriesCount int) int { if chartLegendVisible(seriesCount) { return 360 } return 288 } // globalStats returns min, average, and max across all values in all datasets. func globalStats(datasets [][]float64) (mn, avg, mx float64) { var sum float64 var count int first := true for _, ds := range datasets { for _, v := range ds { if first { mn, mx = v, v first = false } if v < mn { mn = v } if v > mx { mx = v } sum += v count++ } } if count > 0 { avg = sum / float64(count) } return mn, avg, mx } func sanitizeChartText(s string) string { if s == "" { return "" } return html.EscapeString(strings.Map(func(r rune) rune { if r < 0x20 && r != '\t' && r != '\n' && r != '\r' { return -1 } return r }, s)) } func snapshotFanRings(rings []*metricsRing, fanNames []string) ([][]float64, []string, []string) { var datasets [][]float64 var names []string var labels []string for i, ring := range rings { if ring == nil { continue } vals, l := ring.snapshot() datasets = append(datasets, normalizeFanSeries(vals)) name := "Fan" if i < len(fanNames) { name = fanNames[i] } names = append(names, name+" RPM") if len(labels) == 0 { labels = l } } return datasets, names, labels } func chartLegendNumber(v float64) string { neg := v < 0 if v < 0 { v = -v } var out string switch { case v >= 10000: out = fmt.Sprintf("%dk", int((v+500)/1000)) case v >= 1000: s := fmt.Sprintf("%.2f", v/1000) s = strings.TrimRight(strings.TrimRight(s, "0"), ".") out = strings.ReplaceAll(s, ".", ",") + "k" default: out = fmt.Sprintf("%.0f", v) } if neg { return "-" + out } return out } func chartYAxisNumber(v float64) string { neg := v < 0 if neg { v = -v } var out string switch { case v >= 10000: out = fmt.Sprintf("%dк", int((v+500)/1000)) case v >= 1000: // Use one decimal place so ticks like 1400, 1600, 1800 read as // "1,4к", "1,6к", "1,8к" instead of the ambiguous "1к"/"2к". s := fmt.Sprintf("%.1f", v/1000) s = strings.TrimRight(strings.TrimRight(s, "0"), ".") out = strings.ReplaceAll(s, ".", ",") + "к" default: out = fmt.Sprintf("%.0f", v) } if neg { return "-" + out } return out }