- nvidia.go: add Name field to nvidiaGPUInfo, include model name in nvidia-smi query, set dev.Model in enrichPCIeWithNVIDIAData - pages.go: fix duplicate GPU count in validate card summary (4 GPU: 4 x … → 4 x … GPU); fix PSU UNKNOWN fallback from hw.PowerSupplies; treat activating/deactivating/reloading service states as OK in Runtime Health - support_bundle.go: use "150405" time format (no colons) for exFAT compat - sat.go / benchmark.go / platform_stress.go / sat_fan_stress.go: remove .tar.gz archive creation from export dirs — export packs everything itself - charts_svg.go: add min-max downsampling (1400 pt cap) for SVG chart perf - benchmark_report.go / sat.go: normalize GPU fallback to "Unknown GPU" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
872 lines
25 KiB
Go
872 lines
25 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// Downsample to at most ~1400 points (one per pixel) before building SVG.
|
|
times, datasets = downsampleTimeSeries(times, datasets, 1400)
|
|
pointCount = len(times)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Downsample to at most ~1400 points before building SVG.
|
|
{
|
|
datasets := make([][]float64, len(series))
|
|
for i := range series {
|
|
datasets[i] = series[i].Values
|
|
}
|
|
times, datasets = downsampleTimeSeries(times, datasets, 1400)
|
|
pointCount = len(times)
|
|
for i := range series {
|
|
series[i].Values = datasets[i]
|
|
}
|
|
}
|
|
|
|
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, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="1"/>`+"\n",
|
|
axisLineX, layout.PlotTop, axisLineX, layout.PlotBottom, series[i].Color)
|
|
fmt.Fprintf(&b, `<text x="%d" y="%d" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="%s">%s</text>`+"\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, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="1"/>`+"\n",
|
|
axisLineX, y, axisLineX+6, y, series[i].Color)
|
|
fmt.Fprintf(&b, `<text x="%d" y="%.1f" text-anchor="end" dy="4" font-family="sans-serif" font-size="10" fill="%s">%s</text>`+"\n",
|
|
axisLineX-8, y, series[i].Color, label)
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="1"/>`+"\n",
|
|
axisLineX, y, axisLineX-6, y, series[i].Color)
|
|
fmt.Fprintf(&b, `<text x="%d" y="%.1f" text-anchor="start" dy="4" font-family="sans-serif" font-size="10" fill="%s">%s</text>`+"\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, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`+"\n", width, height, width, height)
|
|
}
|
|
|
|
func writeSVGClose(b *strings.Builder) {
|
|
b.WriteString("</svg>\n")
|
|
}
|
|
|
|
func writeChartFrame(b *strings.Builder, title, subtitle string, width, height int) {
|
|
fmt.Fprintf(b, `<rect width="%d" height="%d" rx="10" ry="10" fill="#ffffff" stroke="#d7e0ea"/>`+"\n", width, height)
|
|
fmt.Fprintf(b, `<text x="%d" y="30" text-anchor="middle" font-family="sans-serif" font-size="16" font-weight="700" fill="#1f2937">%s</text>`+"\n",
|
|
width/2, sanitizeChartText(title))
|
|
if strings.TrimSpace(subtitle) != "" {
|
|
fmt.Fprintf(b, `<text x="%d" y="50" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#64748b">%s</text>`+"\n",
|
|
width/2, sanitizeChartText(subtitle))
|
|
}
|
|
}
|
|
|
|
func writePlotBorder(b *strings.Builder, layout chartLayout) {
|
|
fmt.Fprintf(b, `<rect x="%d" y="%d" width="%d" height="%d" fill="none" stroke="#cbd5e1" stroke-width="1"/>`+"\n",
|
|
layout.PlotLeft, layout.PlotTop, layout.PlotRight-layout.PlotLeft, layout.PlotBottom-layout.PlotTop)
|
|
}
|
|
|
|
func writeHorizontalGrid(b *strings.Builder, layout chartLayout, scale chartScale) {
|
|
b.WriteString(`<g stroke="#e2e8f0" stroke-width="1">` + "\n")
|
|
for _, tick := range scale.Ticks {
|
|
y := chartYForValue(tick, scale, layout.PlotTop, layout.PlotBottom)
|
|
fmt.Fprintf(b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f"/>`+"\n",
|
|
layout.PlotLeft, y, layout.PlotRight, y)
|
|
}
|
|
b.WriteString(`</g>` + "\n")
|
|
}
|
|
|
|
func writeVerticalGrid(b *strings.Builder, layout chartLayout, times []time.Time, pointCount, target int) {
|
|
if pointCount <= 0 {
|
|
return
|
|
}
|
|
start, end := chartTimeBounds(times)
|
|
b.WriteString(`<g stroke="#edf2f7" stroke-width="1">` + "\n")
|
|
for _, idx := range gpuChartLabelIndices(pointCount, target) {
|
|
ts := chartPointTime(times, idx)
|
|
x := chartXForTime(ts, start, end, layout.PlotLeft, layout.PlotRight)
|
|
fmt.Fprintf(b, `<line x1="%.1f" y1="%d" x2="%.1f" y2="%d"/>`+"\n",
|
|
x, layout.PlotTop, x, layout.PlotBottom)
|
|
}
|
|
b.WriteString(`</g>` + "\n")
|
|
}
|
|
|
|
func writeSingleAxisY(b *strings.Builder, layout chartLayout, scale chartScale) {
|
|
fmt.Fprintf(b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#64748b" stroke-width="1"/>`+"\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, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="#64748b" stroke-width="1"/>`+"\n",
|
|
layout.PlotLeft, y, layout.PlotLeft-6, y)
|
|
fmt.Fprintf(b, `<text x="%d" y="%.1f" text-anchor="end" dy="4" font-family="sans-serif" font-size="10" fill="#475569">%s</text>`+"\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(`<g font-family="sans-serif" font-size="11" fill="#64748b" text-anchor="middle">` + "\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, `<text x="%.1f" y="%d">%s</text>`+"\n", x, layout.PlotBottom+28, sanitizeChartText(label))
|
|
}
|
|
b.WriteString(`</g>` + "\n")
|
|
fmt.Fprintf(b, `<text x="%d" y="%d" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#64748b">Time</text>`+"\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, `<polyline points="%s" fill="none" stroke="%s" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>`+"\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, `<circle cx="%.1f" cy="%.1f" r="3.5" fill="%s"/>`+"\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, `<circle cx="%.1f" cy="%.1f" r="4.2" fill="%s" stroke="#ffffff" stroke-width="1.6"/>`+"\n", x, y, color)
|
|
fmt.Fprintf(b, `<path d="M %.1f %.1f L %.1f %.1f L %.1f %.1f Z" fill="%s" opacity="0.9"/>`+"\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, `<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="3"/>`+"\n",
|
|
x, y, x+28, y, item.Color)
|
|
fmt.Fprintf(b, `<text x="%.1f" y="%.1f" font-family="sans-serif" font-size="12" fill="#1f2937">%s</text>`+"\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(`<g data-role="timeline-overlay">` + "\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, `<rect x="%.1f" y="%d" width="%.1f" height="%d" fill="#475569" opacity="0.10"/>`+"\n",
|
|
x0, layout.PlotTop, math.Max(1, x1-x0), layout.PlotBottom-layout.PlotTop)
|
|
}
|
|
b.WriteString(`</g>` + "\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(`<g data-role="timeline-boundaries" stroke="#94a3b8" stroke-width="1.2">` + "\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, `<line x1="%d" y1="%d" x2="%d" y2="%d"/>`+"\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, `<line x1="%d" y1="%d" x2="%d" y2="%d"/>`+"\n", x, layout.PlotTop, x, layout.PlotBottom)
|
|
}
|
|
}
|
|
}
|
|
b.WriteString(`</g>` + "\n")
|
|
}
|
|
|
|
// downsampleTimeSeries reduces the time series to at most maxPts points using
|
|
// min-max bucketing. Each bucket contributes the index of its min and max value
|
|
// (using the first full-length dataset as the reference series). All parallel
|
|
// datasets are sampled at those same indices so all series stay aligned.
|
|
// If len(times) <= maxPts the inputs are returned unchanged.
|
|
func downsampleTimeSeries(times []time.Time, datasets [][]float64, maxPts int) ([]time.Time, [][]float64) {
|
|
n := len(times)
|
|
if n <= maxPts || maxPts <= 0 {
|
|
return times, datasets
|
|
}
|
|
buckets := maxPts / 2
|
|
if buckets < 1 {
|
|
buckets = 1
|
|
}
|
|
// Use the first dataset that has the same length as times as the reference
|
|
// for deciding which two indices to keep per bucket.
|
|
var ref []float64
|
|
for _, ds := range datasets {
|
|
if len(ds) == n {
|
|
ref = ds
|
|
break
|
|
}
|
|
}
|
|
selected := make([]int, 0, maxPts)
|
|
bucketSize := float64(n) / float64(buckets)
|
|
for b := 0; b < buckets; b++ {
|
|
lo := int(math.Round(float64(b) * bucketSize))
|
|
hi := int(math.Round(float64(b+1) * bucketSize))
|
|
if hi > n {
|
|
hi = n
|
|
}
|
|
if lo >= hi {
|
|
continue
|
|
}
|
|
if ref == nil {
|
|
selected = append(selected, lo)
|
|
if hi-1 != lo {
|
|
selected = append(selected, hi-1)
|
|
}
|
|
continue
|
|
}
|
|
minIdx, maxIdx := lo, lo
|
|
for i := lo + 1; i < hi; i++ {
|
|
if ref[i] < ref[minIdx] {
|
|
minIdx = i
|
|
}
|
|
if ref[i] > ref[maxIdx] {
|
|
maxIdx = i
|
|
}
|
|
}
|
|
if minIdx <= maxIdx {
|
|
selected = append(selected, minIdx)
|
|
if maxIdx != minIdx {
|
|
selected = append(selected, maxIdx)
|
|
}
|
|
} else {
|
|
selected = append(selected, maxIdx)
|
|
if minIdx != maxIdx {
|
|
selected = append(selected, minIdx)
|
|
}
|
|
}
|
|
}
|
|
outTimes := make([]time.Time, len(selected))
|
|
for i, idx := range selected {
|
|
outTimes[i] = times[idx]
|
|
}
|
|
outDatasets := make([][]float64, len(datasets))
|
|
for d, ds := range datasets {
|
|
if len(ds) != n {
|
|
outDatasets[d] = ds
|
|
continue
|
|
}
|
|
out := make([]float64, len(selected))
|
|
for i, idx := range selected {
|
|
out[i] = ds[idx]
|
|
}
|
|
outDatasets[d] = out
|
|
}
|
|
return outTimes, outDatasets
|
|
}
|
|
|
|
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
|
|
}
|