666 lines
16 KiB
Go
666 lines
16 KiB
Go
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
|
|
}
|
|
|
|
// psuSlotsFromSamples returns the sorted list of PSU slot numbers seen across samples.
|
|
func psuSlotsFromSamples(samples []platform.LiveMetricSample) []int {
|
|
seen := map[int]struct{}{}
|
|
for _, s := range samples {
|
|
for _, p := range s.PSUs {
|
|
seen[p.Slot] = struct{}{}
|
|
}
|
|
}
|
|
slots := make([]int, 0, len(seen))
|
|
for s := range seen {
|
|
slots = append(slots, s)
|
|
}
|
|
sort.Ints(slots)
|
|
return slots
|
|
}
|
|
|
|
// psuStackedTotal returns the point-by-point sum of all PSU datasets (for scale calculation).
|
|
func psuStackedTotal(datasets [][]float64) []float64 {
|
|
if len(datasets) == 0 {
|
|
return nil
|
|
}
|
|
n := len(datasets[0])
|
|
total := make([]float64, n)
|
|
for _, ds := range datasets {
|
|
for i, v := range ds {
|
|
total[i] += v
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
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
|
|
}
|