diff --git a/audit/internal/platform/gpu_metrics.go b/audit/internal/platform/gpu_metrics.go index 0bec459..0873875 100644 --- a/audit/internal/platform/gpu_metrics.go +++ b/audit/internal/platform/gpu_metrics.go @@ -20,12 +20,13 @@ type GPUMetricRow struct { MemUsagePct float64 `json:"mem_usage_pct"` PowerW float64 `json:"power_w"` ClockMHz float64 `json:"clock_mhz"` + MemClockMHz float64 `json:"mem_clock_mhz"` } // sampleGPUMetrics runs nvidia-smi once and returns current metrics for each GPU. func sampleGPUMetrics(gpuIndices []int) ([]GPUMetricRow, error) { args := []string{ - "--query-gpu=index,temperature.gpu,utilization.gpu,utilization.memory,power.draw,clocks.current.graphics", + "--query-gpu=index,temperature.gpu,utilization.gpu,utilization.memory,power.draw,clocks.current.graphics,clocks.current.memory", "--format=csv,noheader,nounits", } if len(gpuIndices) > 0 { @@ -46,7 +47,7 @@ func sampleGPUMetrics(gpuIndices []int) ([]GPUMetricRow, error) { continue } parts := strings.Split(line, ", ") - if len(parts) < 6 { + if len(parts) < 7 { continue } idx, _ := strconv.Atoi(strings.TrimSpace(parts[0])) @@ -57,6 +58,7 @@ func sampleGPUMetrics(gpuIndices []int) ([]GPUMetricRow, error) { MemUsagePct: parseGPUFloat(parts[3]), PowerW: parseGPUFloat(parts[4]), ClockMHz: parseGPUFloat(parts[5]), + MemClockMHz: parseGPUFloat(parts[6]), }) } return rows, nil @@ -139,10 +141,10 @@ func sampleAMDGPUMetrics() ([]GPUMetricRow, error) { // WriteGPUMetricsCSV writes collected rows as a CSV file. func WriteGPUMetricsCSV(path string, rows []GPUMetricRow) error { var b bytes.Buffer - b.WriteString("elapsed_sec,gpu_index,temperature_c,usage_pct,power_w,clock_mhz\n") + b.WriteString("elapsed_sec,gpu_index,temperature_c,usage_pct,power_w,clock_mhz,mem_clock_mhz\n") for _, r := range rows { - fmt.Fprintf(&b, "%.1f,%d,%.1f,%.1f,%.1f,%.0f\n", - r.ElapsedSec, r.GPUIndex, r.TempC, r.UsagePct, r.PowerW, r.ClockMHz) + fmt.Fprintf(&b, "%.1f,%d,%.1f,%.1f,%.1f,%.0f,%.0f\n", + r.ElapsedSec, r.GPUIndex, r.TempC, r.UsagePct, r.PowerW, r.ClockMHz, r.MemClockMHz) } return os.WriteFile(path, b.Bytes(), 0644) } @@ -197,7 +199,7 @@ func drawGPUChartSVG(rows []GPUMetricRow, gpuIdx int) string { const PW = plotX2 - plotX1 const PH = plotY2 - plotY1 // Outer axes - const tempAxisX = 60 // temp axis line + const tempAxisX = 60 // temp axis line const clockAxisX = 900 // clock axis line colors := [4]string{"#e74c3c", "#3498db", "#2ecc71", "#f39c12"} diff --git a/audit/internal/webui/metricsdb.go b/audit/internal/webui/metricsdb.go index 090f3bd..61379c2 100644 --- a/audit/internal/webui/metricsdb.go +++ b/audit/internal/webui/metricsdb.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sort" "strconv" + "strings" "time" "bee/audit/internal/platform" @@ -54,6 +55,8 @@ CREATE TABLE IF NOT EXISTS gpu_metrics ( usage_pct REAL, mem_usage_pct REAL, power_w REAL, + clock_mhz REAL, + mem_clock_mhz REAL, PRIMARY KEY (ts, gpu_index) ); CREATE TABLE IF NOT EXISTS fan_metrics ( @@ -70,6 +73,38 @@ CREATE TABLE IF NOT EXISTS temp_metrics ( PRIMARY KEY (ts, name) ); `) + if err != nil { + return err + } + if err := ensureMetricsColumn(db, "gpu_metrics", "clock_mhz", "REAL"); err != nil { + return err + } + return ensureMetricsColumn(db, "gpu_metrics", "mem_clock_mhz", "REAL") +} + +func ensureMetricsColumn(db *sql.DB, table, column, definition string) error { + rows, err := db.Query("PRAGMA table_info(" + table + ")") + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var cid int + var name, ctype string + var notNull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &ctype, ¬Null, &dflt, &pk); err != nil { + return err + } + if strings.EqualFold(name, column) { + return nil + } + } + if err := rows.Err(); err != nil { + return err + } + _, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition) return err } @@ -91,8 +126,8 @@ func (m *MetricsDB) Write(s platform.LiveMetricSample) error { } for _, g := range s.GPUs { _, err = tx.Exec( - `INSERT OR REPLACE INTO gpu_metrics(ts,gpu_index,temp_c,usage_pct,mem_usage_pct,power_w) VALUES(?,?,?,?,?,?)`, - ts, g.GPUIndex, g.TempC, g.UsagePct, g.MemUsagePct, g.PowerW, + `INSERT OR REPLACE INTO gpu_metrics(ts,gpu_index,temp_c,usage_pct,mem_usage_pct,power_w,clock_mhz,mem_clock_mhz) VALUES(?,?,?,?,?,?,?,?)`, + ts, g.GPUIndex, g.TempC, g.UsagePct, g.MemUsagePct, g.PowerW, g.ClockMHz, g.MemClockMHz, ) if err != nil { return err @@ -163,7 +198,7 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri } gpuData := map[gpuKey]platform.GPUMetricRow{} gRows, err := m.db.Query( - `SELECT ts,gpu_index,temp_c,usage_pct,mem_usage_pct,power_w FROM gpu_metrics WHERE ts>=? AND ts<=? ORDER BY ts,gpu_index`, + `SELECT ts,gpu_index,temp_c,usage_pct,mem_usage_pct,power_w,IFNULL(clock_mhz,0),IFNULL(mem_clock_mhz,0) FROM gpu_metrics WHERE ts>=? AND ts<=? ORDER BY ts,gpu_index`, minTS, maxTS, ) if err == nil { @@ -171,7 +206,7 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri for gRows.Next() { var ts int64 var g platform.GPUMetricRow - if err := gRows.Scan(&ts, &g.GPUIndex, &g.TempC, &g.UsagePct, &g.MemUsagePct, &g.PowerW); err == nil { + if err := gRows.Scan(&ts, &g.GPUIndex, &g.TempC, &g.UsagePct, &g.MemUsagePct, &g.PowerW, &g.ClockMHz, &g.MemClockMHz); err == nil { gpuData[gpuKey{ts, g.GPUIndex}] = g } } @@ -283,7 +318,8 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri func (m *MetricsDB) ExportCSV(w io.Writer) error { rows, err := m.db.Query(` SELECT s.ts, s.cpu_load_pct, s.mem_load_pct, s.power_w, - g.gpu_index, g.temp_c, g.usage_pct, g.mem_usage_pct, g.power_w + g.gpu_index, g.temp_c, g.usage_pct, g.mem_usage_pct, g.power_w, + g.clock_mhz, g.mem_clock_mhz FROM sys_metrics s LEFT JOIN gpu_metrics g ON g.ts = s.ts ORDER BY s.ts, g.gpu_index @@ -294,13 +330,13 @@ func (m *MetricsDB) ExportCSV(w io.Writer) error { defer rows.Close() cw := csv.NewWriter(w) - _ = cw.Write([]string{"ts", "cpu_load_pct", "mem_load_pct", "sys_power_w", "gpu_index", "gpu_temp_c", "gpu_usage_pct", "gpu_mem_pct", "gpu_power_w"}) + _ = cw.Write([]string{"ts", "cpu_load_pct", "mem_load_pct", "sys_power_w", "gpu_index", "gpu_temp_c", "gpu_usage_pct", "gpu_mem_pct", "gpu_power_w", "gpu_clock_mhz", "gpu_mem_clock_mhz"}) for rows.Next() { var ts int64 var cpu, mem, pwr float64 var gpuIdx sql.NullInt64 - var gpuTemp, gpuUse, gpuMem, gpuPow sql.NullFloat64 - if err := rows.Scan(&ts, &cpu, &mem, &pwr, &gpuIdx, &gpuTemp, &gpuUse, &gpuMem, &gpuPow); err != nil { + var gpuTemp, gpuUse, gpuMem, gpuPow, gpuClock, gpuMemClock sql.NullFloat64 + if err := rows.Scan(&ts, &cpu, &mem, &pwr, &gpuIdx, &gpuTemp, &gpuUse, &gpuMem, &gpuPow, &gpuClock, &gpuMemClock); err != nil { continue } row := []string{ @@ -316,9 +352,11 @@ func (m *MetricsDB) ExportCSV(w io.Writer) error { strconv.FormatFloat(gpuUse.Float64, 'f', 1, 64), strconv.FormatFloat(gpuMem.Float64, 'f', 1, 64), strconv.FormatFloat(gpuPow.Float64, 'f', 1, 64), + strconv.FormatFloat(gpuClock.Float64, 'f', 1, 64), + strconv.FormatFloat(gpuMemClock.Float64, 'f', 1, 64), ) } else { - row = append(row, "", "", "", "", "") + row = append(row, "", "", "", "", "", "", "") } _ = cw.Write(row) } diff --git a/audit/internal/webui/metricsdb_test.go b/audit/internal/webui/metricsdb_test.go index f2484fa..9ac264d 100644 --- a/audit/internal/webui/metricsdb_test.go +++ b/audit/internal/webui/metricsdb_test.go @@ -1,11 +1,13 @@ package webui import ( + "database/sql" "path/filepath" "testing" "time" "bee/audit/internal/platform" + _ "modernc.org/sqlite" ) func TestMetricsDBLoadSamplesKeepsChronologicalRangeForGPUs(t *testing.T) { @@ -67,3 +69,77 @@ func TestMetricsDBLoadSamplesKeepsChronologicalRangeForGPUs(t *testing.T) { } } } + +func TestMetricsDBMigratesLegacyGPUSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "metrics.db") + raw, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + _, err = raw.Exec(` +CREATE TABLE gpu_metrics ( + ts INTEGER NOT NULL, + gpu_index INTEGER NOT NULL, + temp_c REAL, + usage_pct REAL, + mem_usage_pct REAL, + power_w REAL, + PRIMARY KEY (ts, gpu_index) +); +CREATE TABLE sys_metrics ( + ts INTEGER NOT NULL, + cpu_load_pct REAL, + mem_load_pct REAL, + power_w REAL, + PRIMARY KEY (ts) +); +CREATE TABLE fan_metrics ( + ts INTEGER NOT NULL, + name TEXT NOT NULL, + rpm REAL, + PRIMARY KEY (ts, name) +); +CREATE TABLE temp_metrics ( + ts INTEGER NOT NULL, + name TEXT NOT NULL, + grp TEXT NOT NULL, + celsius REAL, + PRIMARY KEY (ts, name) +); +`) + if err != nil { + t.Fatalf("create legacy schema: %v", err) + } + _ = raw.Close() + + db, err := openMetricsDB(path) + if err != nil { + t.Fatalf("openMetricsDB: %v", err) + } + defer db.Close() + + now := time.Unix(1_700_000_100, 0).UTC() + err = db.Write(platform.LiveMetricSample{ + Timestamp: now, + GPUs: []platform.GPUMetricRow{ + {GPUIndex: 0, ClockMHz: 1410, MemClockMHz: 2600}, + }, + }) + if err != nil { + t.Fatalf("Write: %v", err) + } + + samples, err := db.LoadAll() + if err != nil { + t.Fatalf("LoadAll: %v", err) + } + if len(samples) != 1 || len(samples[0].GPUs) != 1 { + t.Fatalf("samples=%+v", samples) + } + if got := samples[0].GPUs[0].ClockMHz; got != 1410 { + t.Fatalf("ClockMHz=%v want 1410", got) + } + if got := samples[0].GPUs[0].MemClockMHz; got != 2600 { + t.Fatalf("MemClockMHz=%v want 2600", got) + } +} diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index 7116ec7..1323324 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -464,14 +464,14 @@ func renderMetrics() string {
Server — Load
- CPU/Mem load + CPU/Mem load
Temperature — CPU
- CPU temperature + CPU temperature
@@ -479,57 +479,84 @@ func renderMetrics() string {
Temperature — Ambient Sensors
- Ambient temperature sensors + Ambient temperature sensors
Server — Power
- System power + System power
-
-
GPU — Compute Load
-
- GPU compute load +
-
-
GPU — Memory Load
-
- GPU memory load + +
+
+
GPU — Compute Load
+
+ GPU compute load +
+
+
+
GPU — Memory Load
+
+ GPU memory load +
+
+
+
GPU — Core Clock
+
+ GPU core clock +
+
+
+
GPU — Memory Clock
+
+ GPU memory clock +
+
+
+
GPU — Power
+
+ GPU power +
+
+
+
GPU — Temperature
+
+ GPU temperature +
+
-
-
-
GPU — Power
-
- GPU power -
-
-
-
GPU — Temperature
-
- GPU temperature -
-
+ + + ` } diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go index b437c49..ab85603 100644 --- a/audit/internal/webui/server.go +++ b/audit/internal/webui/server.go @@ -6,11 +6,13 @@ import ( "fmt" "html" "log/slog" + "math" "mime" "net/http" "os" "path/filepath" "sort" + "strconv" "strings" "sync" "time" @@ -475,6 +477,26 @@ func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request) http.Error(w, "metrics database not available", http.StatusServiceUnavailable) return } + 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) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if !ok { + 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, ok := h.chartDataFromDB(path) if !ok { http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable) @@ -578,15 +600,21 @@ func chartDataFromSamples(path string, samples []platform.LiveMetricSample) ([][ 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/"): - rest := strings.TrimPrefix(path, "gpu/") - sub := "" - if i := strings.LastIndex(rest, "-"); i > 0 { - sub = rest[i+1:] - rest = rest[:i] + idx, sub, ok := parseGPUChartPath(path) + if !ok { + return nil, nil, nil, "", nil, nil, false } - idx := 0 - fmt.Sscanf(rest, "%d", &idx) switch sub { case "load": title = fmt.Sprintf("GPU %d Load", idx) @@ -609,6 +637,24 @@ func chartDataFromSamples(path string, samples []platform.LiveMetricSample) ([][ names = []string{"Temp °C"} yMin = floatPtr(0) yMax = autoMax120(temp) + case "clock": + title = fmt.Sprintf("GPU %d Core Clock", idx) + clock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.ClockMHz }) + if clock == nil { + return nil, nil, nil, "", nil, nil, false + } + datasets = [][]float64{clock} + names = []string{"Core Clock MHz"} + yMin, yMax = autoBounds120(clock) + case "memclock": + title = fmt.Sprintf("GPU %d Memory Clock", idx) + clock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.MemClockMHz }) + if clock == nil { + return nil, nil, nil, "", nil, nil, false + } + datasets = [][]float64{clock} + names = []string{"Memory Clock MHz"} + yMin, yMax = autoBounds120(clock) default: title = fmt.Sprintf("GPU %d Power", idx) power := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.PowerW }) @@ -627,6 +673,26 @@ func chartDataFromSamples(path string, samples []platform.LiveMetricSample) ([][ return datasets, names, labels, title, yMin, yMax, 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 { @@ -852,6 +918,268 @@ 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 = 420 + plotLeft = 180 + plotRight = 1220 + plotTop = 74 + plotBottom = 292 + ) + 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, 52, 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+22, 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 = 372 + 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 + } + 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 +} + // 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) { n := len(labels) diff --git a/audit/internal/webui/server_test.go b/audit/internal/webui/server_test.go index 64ede77..c67954f 100644 --- a/audit/internal/webui/server_test.go +++ b/audit/internal/webui/server_test.go @@ -136,6 +136,53 @@ func TestChartDataFromSamplesKeepsStableGPUSeriesOrder(t *testing.T) { } } +func TestChartDataFromSamplesIncludesGPUClockCharts(t *testing.T) { + samples := []platform.LiveMetricSample{ + { + Timestamp: time.Now().Add(-2 * time.Minute), + GPUs: []platform.GPUMetricRow{ + {GPUIndex: 0, ClockMHz: 1400, MemClockMHz: 2600}, + {GPUIndex: 3, ClockMHz: 1500, MemClockMHz: 2800}, + }, + }, + { + Timestamp: time.Now().Add(-1 * time.Minute), + GPUs: []platform.GPUMetricRow{ + {GPUIndex: 0, ClockMHz: 1410, MemClockMHz: 2610}, + {GPUIndex: 3, ClockMHz: 1510, MemClockMHz: 2810}, + }, + }, + } + + datasets, names, _, title, _, _, ok := chartDataFromSamples("gpu-all-clock", samples) + if !ok { + t.Fatal("gpu-all-clock returned ok=false") + } + if title != "GPU Core Clock" { + t.Fatalf("title=%q", title) + } + if len(names) != 2 || names[0] != "GPU 0" || names[1] != "GPU 3" { + t.Fatalf("names=%v", names) + } + if got := datasets[1][1]; got != 1510 { + t.Fatalf("GPU 3 core clock=%v want 1510", got) + } + + datasets, names, _, title, _, _, ok = chartDataFromSamples("gpu-all-memclock", samples) + if !ok { + t.Fatal("gpu-all-memclock returned ok=false") + } + if title != "GPU Memory Clock" { + t.Fatalf("title=%q", title) + } + if len(names) != 2 || names[0] != "GPU 0" || names[1] != "GPU 3" { + t.Fatalf("names=%v", names) + } + if got := datasets[0][0]; got != 2600 { + t.Fatalf("GPU 0 memory clock=%v want 2600", got) + } +} + func TestNormalizePowerSeriesHoldsLastPositive(t *testing.T) { got := normalizePowerSeries([]float64{0, 480, 0, 0, 510, 0}) want := []float64{0, 480, 480, 480, 510, 510} @@ -157,6 +204,21 @@ func TestRenderMetricsUsesBufferedChartRefresh(t *testing.T) { if !strings.Contains(body, "el.dataset.loading === '1'") { t.Fatalf("metrics page should avoid overlapping chart reloads: %s", body) } + if !strings.Contains(body, `id="gpu-metrics-section" style="display:none`) { + t.Fatalf("metrics page should keep gpu charts in a hidden dedicated section until GPUs are detected: %s", body) + } + if !strings.Contains(body, `id="gpu-chart-toggle"`) { + t.Fatalf("metrics page should render GPU chart mode toggle: %s", body) + } + if !strings.Contains(body, `/api/metrics/chart/gpu-all-clock.svg`) { + t.Fatalf("metrics page should include GPU core clock chart: %s", body) + } + if !strings.Contains(body, `/api/metrics/chart/gpu-all-memclock.svg`) { + t.Fatalf("metrics page should include GPU memory clock chart: %s", body) + } + if !strings.Contains(body, `renderGPUOverviewCards(indices)`) { + t.Fatalf("metrics page should build per-GPU chart cards dynamically: %s", body) + } } func TestChartLegendVisible(t *testing.T) {