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