feat(metrics): single chart engine + full-width stacked layout
- One engine: go-analyze/charts (grafana theme) for all live metrics - Server chart: CPU temp, CPU load%, mem load%, power W, fan RPMs - GPU charts: temp, load%, mem%, power W — one card per GPU, added dynamically - Charts 1400x280px SVG, rendered at width:100% in single-column layout - Add CPU load (from /proc/stat) and mem load (from /proc/meminfo) to LiveMetricSample - Add GPU mem utilization to GPUMetricRow (nvidia-smi utilization.memory) - Document charting architecture in bible-local/architecture/charting.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -62,15 +62,27 @@ func (r *metricsRing) snapshot() ([]float64, []string) {
|
||||
return v, l
|
||||
}
|
||||
|
||||
// gpuRings holds per-GPU ring buffers.
|
||||
type gpuRings struct {
|
||||
Temp *metricsRing
|
||||
Util *metricsRing
|
||||
MemUtil *metricsRing
|
||||
Power *metricsRing
|
||||
}
|
||||
|
||||
// handler is the HTTP handler for the web UI.
|
||||
type handler struct {
|
||||
opts HandlerOptions
|
||||
mux *http.ServeMux
|
||||
opts HandlerOptions
|
||||
mux *http.ServeMux
|
||||
// server rings
|
||||
ringCPUTemp *metricsRing
|
||||
ringCPULoad *metricsRing
|
||||
ringMemLoad *metricsRing
|
||||
ringPower *metricsRing
|
||||
ringFans []*metricsRing
|
||||
ringGPUTemp []*metricsRing
|
||||
ringGPUUtil []*metricsRing
|
||||
fanNames []string
|
||||
// per-GPU rings (index = GPU index)
|
||||
gpuRings []*gpuRings
|
||||
ringsMu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -89,6 +101,8 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
||||
h := &handler{
|
||||
opts: opts,
|
||||
ringCPUTemp: newMetricsRing(120),
|
||||
ringCPULoad: newMetricsRing(120),
|
||||
ringMemLoad: newMetricsRing(120),
|
||||
ringPower: newMetricsRing(120),
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
@@ -244,48 +258,88 @@ func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/metrics/chart/")
|
||||
name = strings.TrimSuffix(name, ".svg")
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/metrics/chart/")
|
||||
path = strings.TrimSuffix(path, ".svg")
|
||||
|
||||
var datasets [][]float64
|
||||
var names []string
|
||||
var labels []string
|
||||
var title string
|
||||
|
||||
switch {
|
||||
case path == "server":
|
||||
title = "Server"
|
||||
vCPUTemp, l := h.ringCPUTemp.snapshot()
|
||||
vCPULoad, _ := h.ringCPULoad.snapshot()
|
||||
vMemLoad, _ := h.ringMemLoad.snapshot()
|
||||
vPower, _ := h.ringPower.snapshot()
|
||||
labels = l
|
||||
datasets = [][]float64{vCPUTemp, vCPULoad, vMemLoad, vPower}
|
||||
names = []string{"CPU Temp °C", "CPU Load %", "Mem Load %", "Power W"}
|
||||
|
||||
h.ringsMu.Lock()
|
||||
for i, fr := range h.ringFans {
|
||||
fv, _ := fr.snapshot()
|
||||
datasets = append(datasets, fv)
|
||||
name := "Fan"
|
||||
if i < len(h.fanNames) {
|
||||
name = h.fanNames[i]
|
||||
}
|
||||
names = append(names, name+" RPM")
|
||||
}
|
||||
h.ringsMu.Unlock()
|
||||
|
||||
case strings.HasPrefix(path, "gpu/"):
|
||||
idxStr := strings.TrimPrefix(path, "gpu/")
|
||||
idx := 0
|
||||
fmt.Sscanf(idxStr, "%d", &idx)
|
||||
h.ringsMu.Lock()
|
||||
var gr *gpuRings
|
||||
if idx < len(h.gpuRings) {
|
||||
gr = h.gpuRings[idx]
|
||||
}
|
||||
h.ringsMu.Unlock()
|
||||
if gr == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
vTemp, l := gr.Temp.snapshot()
|
||||
vUtil, _ := gr.Util.snapshot()
|
||||
vMemUtil, _ := gr.MemUtil.snapshot()
|
||||
vPower, _ := gr.Power.snapshot()
|
||||
labels = l
|
||||
title = fmt.Sprintf("GPU %d", idx)
|
||||
datasets = [][]float64{vTemp, vUtil, vMemUtil, vPower}
|
||||
names = []string{"Temp °C", "Load %", "Mem %", "Power W"}
|
||||
|
||||
var ring *metricsRing
|
||||
var title, unit string
|
||||
switch name {
|
||||
case "cpu-temp":
|
||||
ring, title, unit = h.ringCPUTemp, "CPU Temperature", "°C"
|
||||
case "power":
|
||||
ring, title, unit = h.ringPower, "System Power", "W"
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
vals, labels := ring.snapshot()
|
||||
if len(vals) == 0 {
|
||||
vals = []float64{0}
|
||||
// Ensure all datasets same length as labels
|
||||
n := len(labels)
|
||||
if n == 0 {
|
||||
n = 1
|
||||
labels = []string{""}
|
||||
}
|
||||
|
||||
// Sparse x-axis labels
|
||||
sparse := make([]string, len(labels))
|
||||
step := len(labels) / 6
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
for i := range labels {
|
||||
if i%step == 0 {
|
||||
sparse[i] = labels[i]
|
||||
for i := range datasets {
|
||||
if len(datasets[i]) == 0 {
|
||||
datasets[i] = make([]float64, n)
|
||||
}
|
||||
}
|
||||
|
||||
opt := gocharts.NewLineChartOptionWithData([][]float64{vals})
|
||||
opt.Title = gocharts.TitleOption{Text: title + " (" + unit + ")"}
|
||||
sparse := sparseLabels(labels, 6)
|
||||
|
||||
opt := gocharts.NewLineChartOptionWithData(datasets)
|
||||
opt.Title = gocharts.TitleOption{Text: title}
|
||||
opt.XAxis.Labels = sparse
|
||||
opt.Legend = gocharts.LegendOption{Show: gocharts.Ptr(false)}
|
||||
opt.Legend = gocharts.LegendOption{SeriesNames: names}
|
||||
|
||||
p := gocharts.NewPainter(gocharts.PainterOptions{
|
||||
OutputFormat: gocharts.ChartOutputSVG,
|
||||
Width: 600,
|
||||
Height: 180,
|
||||
Width: 1400,
|
||||
Height: 280,
|
||||
}, gocharts.PainterThemeOption(gocharts.GetTheme("grafana")))
|
||||
if err := p.LineChart(opt); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -301,6 +355,27 @@ func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request)
|
||||
_, _ = w.Write(buf)
|
||||
}
|
||||
|
||||
func safeIdx(s []float64, i int) float64 {
|
||||
if i < len(s) {
|
||||
return s[i]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func sparseLabels(labels []string, n int) []string {
|
||||
out := make([]string, len(labels))
|
||||
step := len(labels) / n
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
for i, l := range labels {
|
||||
if i%step == 0 {
|
||||
out[i] = l
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Page handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *handler) handlePage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user