From 439b86ce59321863c6ae4cd1485ba7c1e0671055 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Wed, 1 Apr 2026 22:19:33 +0300 Subject: [PATCH] Unify live metrics chart rendering --- audit/internal/webui/api.go | 30 ++++++++- audit/internal/webui/pages.go | 75 ++++++++++++++++----- audit/internal/webui/server.go | 84 +++++++++++++++++++----- audit/internal/webui/server_test.go | 98 ++++++++++++++++++++++++++++ bible-local/architecture/charting.md | 31 ++++++++- 5 files changed, 281 insertions(+), 37 deletions(-) diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index 6010443..890debd 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -346,8 +346,10 @@ func (h *handler) handleAPINetworkStatus(w http.ResponseWriter, r *http.Request) return } writeJSON(w, map[string]any{ - "interfaces": ifaces, - "default_route": h.opts.App.DefaultRoute(), + "interfaces": ifaces, + "default_route": h.opts.App.DefaultRoute(), + "pending_change": h.hasPendingNetworkChange(), + "rollback_in": h.pendingNetworkRollbackIn(), }) } @@ -847,7 +849,10 @@ func (h *handler) applyPendingNetworkChange(apply func() (app.ActionResult, erro return result, err } - pnc := &pendingNetChange{snapshot: snapshot} + pnc := &pendingNetChange{ + snapshot: snapshot, + deadline: time.Now().Add(netRollbackTimeout), + } pnc.timer = time.AfterFunc(netRollbackTimeout, func() { _ = h.opts.App.RestoreNetworkSnapshot(snapshot) h.pendingNetMu.Lock() @@ -864,6 +869,25 @@ func (h *handler) applyPendingNetworkChange(apply func() (app.ActionResult, erro return result, nil } +func (h *handler) hasPendingNetworkChange() bool { + h.pendingNetMu.Lock() + defer h.pendingNetMu.Unlock() + return h.pendingNet != nil +} + +func (h *handler) pendingNetworkRollbackIn() int { + h.pendingNetMu.Lock() + defer h.pendingNetMu.Unlock() + if h.pendingNet == nil { + return 0 + } + remaining := int(time.Until(h.pendingNet.deadline).Seconds()) + if remaining < 1 { + return 1 + } + return remaining +} + func (h *handler) handleAPINetworkConfirm(w http.ResponseWriter, _ *http.Request) { h.pendingNetMu.Lock() pnc := h.pendingNet diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index 32678d6..01fd4a2 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -522,13 +522,30 @@ func renderMetrics() string { ` } diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go index f70be9e..5e7a107 100644 --- a/audit/internal/webui/server.go +++ b/audit/internal/webui/server.go @@ -121,6 +121,7 @@ type namedMetricsRing struct { // pendingNetChange tracks a network state change awaiting confirmation. type pendingNetChange struct { snapshot platform.NetworkSnapshot + deadline time.Time timer *time.Timer mu sync.Mutex } @@ -527,15 +528,7 @@ func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request) case path == "server-fans": title = "Fan RPM" 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") - } + datasets, names, labels = snapshotFanRings(h.ringFans, h.fanNames) h.ringsMu.Unlock() yMin = floatPtr(0) yMax = autoMax120(datasets...) @@ -1044,15 +1037,17 @@ func renderChartSVG(title string, datasets [][]float64, names []string, labels [ opt.Title = gocharts.TitleOption{Text: title} opt.XAxis.Labels = sparse opt.Legend = gocharts.LegendOption{SeriesNames: names} + if chartLegendVisible(len(names)) { + opt.Legend.Offset = gocharts.OffsetStr{Top: gocharts.PositionBottom} + opt.Legend.OverlayChart = gocharts.Ptr(false) + } else { + opt.Legend.Show = gocharts.Ptr(false) + } opt.Symbol = gocharts.SymbolNone // Right padding: reserve space for the MarkLine label (library recommendation). opt.Padding = gocharts.NewBox(20, 20, 80, 20) if yMin != nil || yMax != nil { - opt.YAxis = []gocharts.YAxisOption{{ - Min: yMin, - Max: yMax, - ValueFormatter: chartLegendNumber, - }} + opt.YAxis = []gocharts.YAxisOption{chartYAxisOption(yMin, yMax)} } // Add a single peak mark line on the series that holds the global maximum. @@ -1064,7 +1059,7 @@ func renderChartSVG(title string, datasets [][]float64, names []string, labels [ p := gocharts.NewPainter(gocharts.PainterOptions{ OutputFormat: gocharts.ChartOutputSVG, Width: 1400, - Height: 240, + Height: chartCanvasHeight(len(names)), }, gocharts.PainterThemeOption(gocharts.GetTheme("grafana"))) if err := p.LineChart(opt); err != nil { return nil, err @@ -1072,6 +1067,26 @@ func renderChartSVG(title string, datasets [][]float64, names []string, labels [ return p.Bytes() } +func chartLegendVisible(seriesCount int) bool { + return seriesCount <= 8 +} + +func chartCanvasHeight(seriesCount int) int { + if chartLegendVisible(seriesCount) { + return 360 + } + return 288 +} + +func chartYAxisOption(yMin, yMax *float64) gocharts.YAxisOption { + return gocharts.YAxisOption{ + Min: yMin, + Max: yMax, + LabelCount: 11, + ValueFormatter: chartYAxisNumber, + } +} + // globalPeakSeries returns the index of the series containing the global maximum // value across all datasets, and that maximum value. func globalPeakSeries(datasets [][]float64) (idx int, peak float64) { @@ -1159,6 +1174,28 @@ func snapshotNamedRings(rings []*namedMetricsRing) ([][]float64, []string, []str return datasets, names, labels } +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, 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 { @@ -1181,6 +1218,23 @@ func chartLegendNumber(v float64) string { return out } +func chartYAxisNumber(v float64) string { + neg := v < 0 + if neg { + v = -v + } + var out string + if v >= 1000 { + out = fmt.Sprintf("%dк", int((v+500)/1000)) + } else { + out = fmt.Sprintf("%.0f", v) + } + if neg { + return "-" + out + } + return out +} + func sparseLabels(labels []string, n int) []string { out := make([]string, len(labels)) step := len(labels) / n diff --git a/audit/internal/webui/server_test.go b/audit/internal/webui/server_test.go index d929692..5e075e2 100644 --- a/audit/internal/webui/server_test.go +++ b/audit/internal/webui/server_test.go @@ -102,6 +102,104 @@ func TestNormalizePowerSeriesHoldsLastPositive(t *testing.T) { } } +func TestRenderMetricsUsesBufferedChartRefresh(t *testing.T) { + body := renderMetrics() + if !strings.Contains(body, "const probe = new Image();") { + t.Fatalf("metrics page should preload chart images before swap: %s", body) + } + if !strings.Contains(body, "el.dataset.loading === '1'") { + t.Fatalf("metrics page should avoid overlapping chart reloads: %s", body) + } +} + +func TestChartLegendVisible(t *testing.T) { + if !chartLegendVisible(8) { + t.Fatal("legend should stay visible for charts with up to 8 series") + } + if chartLegendVisible(9) { + t.Fatal("legend should be hidden for charts with more than 8 series") + } +} + +func TestChartYAxisNumber(t *testing.T) { + tests := []struct { + in float64 + want string + }{ + {in: 999, want: "999"}, + {in: 1000, want: "1к"}, + {in: 1370, want: "1к"}, + {in: 1500, want: "2к"}, + {in: 10200, want: "10к"}, + {in: -1499, want: "-1к"}, + } + for _, tc := range tests { + if got := chartYAxisNumber(tc.in); got != tc.want { + t.Fatalf("chartYAxisNumber(%v)=%q want %q", tc.in, got, tc.want) + } + } +} + +func TestChartCanvasHeight(t *testing.T) { + if got := chartCanvasHeight(4); got != 360 { + t.Fatalf("chartCanvasHeight(4)=%d want 360", got) + } + if got := chartCanvasHeight(12); got != 288 { + t.Fatalf("chartCanvasHeight(12)=%d want 288", got) + } +} + +func TestChartYAxisOption(t *testing.T) { + min := floatPtr(0) + max := floatPtr(100) + opt := chartYAxisOption(min, max) + if opt.Min != min || opt.Max != max { + t.Fatalf("chartYAxisOption min/max mismatch: %#v", opt) + } + if opt.LabelCount != 11 { + t.Fatalf("chartYAxisOption labelCount=%d want 11", opt.LabelCount) + } + if got := opt.ValueFormatter(1000); got != "1к" { + t.Fatalf("chartYAxisOption formatter(1000)=%q want 1к", got) + } +} + +func TestSnapshotFanRingsUsesTimelineLabels(t *testing.T) { + r1 := newMetricsRing(4) + r2 := newMetricsRing(4) + r1.push(1000) + r1.push(1100) + r2.push(1200) + r2.push(1300) + + datasets, names, labels := snapshotFanRings([]*metricsRing{r1, r2}, []string{"FAN_A", "FAN_B"}) + if len(datasets) != 2 { + t.Fatalf("datasets=%d want 2", len(datasets)) + } + if len(names) != 2 || names[0] != "FAN_A RPM" || names[1] != "FAN_B RPM" { + t.Fatalf("names=%v", names) + } + if len(labels) != 2 { + t.Fatalf("labels=%v want 2 entries", labels) + } + if labels[0] == "" || labels[1] == "" { + t.Fatalf("labels should contain timeline values, got %v", labels) + } +} + +func TestRenderNetworkInlineSyncsPendingState(t *testing.T) { + body := renderNetworkInline() + if !strings.Contains(body, "d.pending_change") { + t.Fatalf("network UI should read pending network state from API: %s", body) + } + if !strings.Contains(body, "setInterval(loadNetwork, 5000)") { + t.Fatalf("network UI should periodically refresh network state: %s", body) + } + if !strings.Contains(body, "showNetPending(NET_ROLLBACK_SECS)") { + t.Fatalf("network UI should show pending confirmation immediately on apply: %s", body) + } +} + func TestRootRendersDashboard(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.json") diff --git a/bible-local/architecture/charting.md b/bible-local/architecture/charting.md index e2bfae5..a5e90ef 100644 --- a/bible-local/architecture/charting.md +++ b/bible-local/architecture/charting.md @@ -9,6 +9,34 @@ All live metrics charts in the web UI are server-side SVG images served by Go and polled by the browser every 2 seconds via ``. There is no client-side canvas or JS chart library. +## Rule: live charts must be visually uniform + +Live charts are a single UI family, not a set of one-off widgets. New charts and +changes to existing charts must keep the same rendering model and presentation +rules unless there is an explicit architectural decision to diverge. + +Default expectations: + +- same server-side SVG pipeline for all live metrics charts +- same refresh behaviour and failure handling in the browser +- same canvas size class and card layout +- same legend placement policy across charts +- same axis, title, and summary conventions +- no chart-specific visual exceptions added as a quick fix + +Current default for live charts: + +- legend below the plot area when a chart has 8 series or fewer +- legend hidden when a chart has more than 8 series +- 10 equal Y-axis steps across the chart height +- 1400 x 360 SVG canvas with legend +- 1400 x 288 SVG canvas without legend +- full-width card rendering in a single-column stack + +If one chart needs a different layout or legend behaviour, treat that as a +design-level decision affecting the whole chart family, not as a local tweak to +just one endpoint. + ### Why go-analyze/charts - Pure Go, no CGO — builds cleanly inside the live-build container @@ -29,7 +57,8 @@ self-contained SVG renderer used **only** for completed SAT run reports | `GET /api/metrics/chart/server.svg` | CPU temp, CPU load %, mem load %, power W, fan RPMs | | `GET /api/metrics/chart/gpu/{idx}.svg` | GPU temp °C, load %, mem %, power W | -Charts are 1400 × 280 px SVG. The page renders them at `width: 100%` in a +Charts are 1400 × 360 px SVG when the legend is shown, and 1400 × 288 px when +the legend is hidden. The page renders them at `width: 100%` in a single-column layout so they always fill the viewport width. ### Ring buffers