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
-

+
Temperature — CPU
-

+
@@ -479,57 +479,84 @@ func renderMetrics() string {
Temperature — Ambient Sensors
-

+
Server — Power
-

+
Server — Fan RPM
-

+
-
-
GPU — Compute Load
-
-

+
+
+
+
GPU Metrics
+
Detected GPUs are rendered in a dedicated section.
+
+
-
-
-
GPU — Memory Load
-
-

+
+
+
+
GPU — Compute Load
+
+

+
+
+
+
GPU — Memory Load
+
+

+
+
+
+
GPU — Core Clock
+
+

+
+
+
+
GPU — Memory Clock
+
+

+
+
+
+
GPU — Power
+
+

+
+
+
+
GPU — Temperature
+
+

+
+
-
-
-
GPU — Power
-
-

-
-
-
-
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(`
\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) {