package webui import ( "encoding/json" "errors" "fmt" "html" "log/slog" "mime" "net/http" "os" "path/filepath" "sort" "strings" "sync" "time" "bee/audit/internal/app" "bee/audit/internal/platform" "bee/audit/internal/runtimeenv" gocharts "github.com/go-analyze/charts" "reanimator/chart/viewer" "reanimator/chart/web" ) const defaultTitle = "Bee Hardware Audit" func init() { // On some LiveCD ramdisk environments, /usr/share/mime/globs2 exists but // causes an I/O error mid-read. Go's mime package panics (not errors) in // that case, crashing the first HTTP goroutine that serves a static file. // Pre-trigger initialization here with recover so subsequent calls are safe. func() { defer func() { recover() }() //nolint:errcheck mime.TypeByExtension(".gz") }() } // HandlerOptions configures the web UI handler. type HandlerOptions struct { Title string BuildLabel string AuditPath string ExportDir string App *app.App RuntimeMode runtimeenv.Mode } // metricsRing holds a rolling window of live metric samples. type metricsRing struct { mu sync.Mutex vals []float64 times []time.Time size int } func newMetricsRing(size int) *metricsRing { return &metricsRing{size: size, vals: make([]float64, 0, size), times: make([]time.Time, 0, size)} } func (r *metricsRing) push(v float64) { r.mu.Lock() defer r.mu.Unlock() if len(r.vals) >= r.size { r.vals = r.vals[1:] r.times = r.times[1:] } r.vals = append(r.vals, v) r.times = append(r.times, time.Now()) } func (r *metricsRing) snapshot() ([]float64, []string) { r.mu.Lock() defer r.mu.Unlock() v := make([]float64, len(r.vals)) copy(v, r.vals) labels := make([]string, len(r.times)) if len(r.times) == 0 { return v, labels } sameDay := timestampsSameLocalDay(r.times) for i, t := range r.times { labels[i] = formatTimelineLabel(t.Local(), sameDay) } return v, labels } func (r *metricsRing) latest() (float64, bool) { r.mu.Lock() defer r.mu.Unlock() if len(r.vals) == 0 { return 0, false } return r.vals[len(r.vals)-1], true } func timestampsSameLocalDay(times []time.Time) bool { if len(times) == 0 { return true } first := times[0].Local() for _, t := range times[1:] { local := t.Local() if local.Year() != first.Year() || local.YearDay() != first.YearDay() { return false } } return true } func formatTimelineLabel(ts time.Time, sameDay bool) string { if sameDay { return ts.Format("15:04") } return ts.Format("01-02 15:04") } // gpuRings holds per-GPU ring buffers. type gpuRings struct { Temp *metricsRing Util *metricsRing MemUtil *metricsRing Power *metricsRing } type namedMetricsRing struct { Name string Ring *metricsRing } // metricsChartWindow is the number of samples kept in the live ring buffer. // At metricsCollectInterval = 5 s this covers 30 minutes of live history. const metricsChartWindow = 360 var metricsCollectInterval = 5 * time.Second // pendingNetChange tracks a network state change awaiting confirmation. type pendingNetChange struct { snapshot platform.NetworkSnapshot deadline time.Time timer *time.Timer mu sync.Mutex } // handler is the HTTP handler for the web UI. type handler struct { opts HandlerOptions mux *http.ServeMux // server rings ringCPULoad *metricsRing ringMemLoad *metricsRing ringPower *metricsRing ringFans []*metricsRing fanNames []string cpuTempRings []*namedMetricsRing ambientTempRings []*namedMetricsRing // per-GPU rings (index = GPU index) gpuRings []*gpuRings ringsMu sync.Mutex latestMu sync.RWMutex latest *platform.LiveMetricSample // metrics persistence (nil if DB unavailable) metricsDB *MetricsDB // pending network change (rollback on timeout) pendingNet *pendingNetChange pendingNetMu sync.Mutex // kmsg hardware error watcher kmsg *kmsgWatcher } // NewHandler creates the HTTP mux with all routes. func NewHandler(opts HandlerOptions) http.Handler { if strings.TrimSpace(opts.Title) == "" { opts.Title = defaultTitle } if strings.TrimSpace(opts.ExportDir) == "" { opts.ExportDir = app.DefaultExportDir } if opts.RuntimeMode == "" { opts.RuntimeMode = runtimeenv.ModeAuto } h := &handler{ opts: opts, ringCPULoad: newMetricsRing(120), ringMemLoad: newMetricsRing(120), ringPower: newMetricsRing(120), } // Open metrics DB and pre-fill ring buffers from history. if db, err := openMetricsDB(metricsDBPath); err == nil { h.metricsDB = db if samples, err := db.LoadRecent(metricsChartWindow); err == nil { for _, s := range samples { h.feedRings(s) } if len(samples) > 0 { h.setLatestMetric(samples[len(samples)-1]) } } else { slog.Warn("metrics history unavailable", "path", metricsDBPath, "err", err) } } else { slog.Warn("metrics db disabled", "path", metricsDBPath, "err", err) } h.startMetricsCollector() // Start kmsg hardware error watcher if the app (and its status DB) is available. if opts.App != nil { h.kmsg = newKmsgWatcher(opts.App.StatusDB) h.kmsg.start() globalQueue.kmsgWatcher = h.kmsg } globalQueue.startWorker(&opts) mux := http.NewServeMux() // ── Infrastructure ────────────────────────────────────────────────────── mux.HandleFunc("GET /healthz", h.handleHealthz) mux.HandleFunc("GET /api/ready", h.handleReady) // ── Existing read-only endpoints (preserved for compatibility) ────────── mux.HandleFunc("GET /audit.json", h.handleAuditJSON) mux.HandleFunc("GET /runtime-health.json", h.handleRuntimeHealthJSON) mux.HandleFunc("GET /export/support.tar.gz", h.handleSupportBundleDownload) mux.HandleFunc("GET /export/file", h.handleExportFile) mux.HandleFunc("GET /export/", h.handleExportIndex) mux.HandleFunc("GET /viewer", h.handleViewer) // ── API ────────────────────────────────────────────────────────────────── // Audit mux.HandleFunc("POST /api/audit/run", h.handleAPIAuditRun) mux.HandleFunc("GET /api/audit/stream", h.handleAPIAuditStream) // SAT mux.HandleFunc("POST /api/sat/nvidia/run", h.handleAPISATRun("nvidia")) mux.HandleFunc("POST /api/sat/nvidia-stress/run", h.handleAPISATRun("nvidia-stress")) mux.HandleFunc("POST /api/sat/memory/run", h.handleAPISATRun("memory")) mux.HandleFunc("POST /api/sat/storage/run", h.handleAPISATRun("storage")) mux.HandleFunc("POST /api/sat/cpu/run", h.handleAPISATRun("cpu")) mux.HandleFunc("POST /api/sat/amd/run", h.handleAPISATRun("amd")) mux.HandleFunc("POST /api/sat/amd-mem/run", h.handleAPISATRun("amd-mem")) mux.HandleFunc("POST /api/sat/amd-bandwidth/run", h.handleAPISATRun("amd-bandwidth")) mux.HandleFunc("POST /api/sat/amd-stress/run", h.handleAPISATRun("amd-stress")) mux.HandleFunc("POST /api/sat/memory-stress/run", h.handleAPISATRun("memory-stress")) mux.HandleFunc("POST /api/sat/sat-stress/run", h.handleAPISATRun("sat-stress")) mux.HandleFunc("POST /api/sat/platform-stress/run", h.handleAPISATRun("platform-stress")) mux.HandleFunc("GET /api/sat/stream", h.handleAPISATStream) mux.HandleFunc("POST /api/sat/abort", h.handleAPISATAbort) // Tasks mux.HandleFunc("GET /api/tasks", h.handleAPITasksList) mux.HandleFunc("POST /api/tasks/cancel-all", h.handleAPITasksCancelAll) mux.HandleFunc("POST /api/tasks/kill-workers", h.handleAPITasksKillWorkers) mux.HandleFunc("POST /api/tasks/{id}/cancel", h.handleAPITasksCancel) mux.HandleFunc("POST /api/tasks/{id}/priority", h.handleAPITasksPriority) mux.HandleFunc("GET /api/tasks/{id}/stream", h.handleAPITasksStream) // Services mux.HandleFunc("GET /api/services", h.handleAPIServicesList) mux.HandleFunc("POST /api/services/action", h.handleAPIServicesAction) // Network mux.HandleFunc("GET /api/network", h.handleAPINetworkStatus) mux.HandleFunc("POST /api/network/dhcp", h.handleAPINetworkDHCP) mux.HandleFunc("POST /api/network/static", h.handleAPINetworkStatic) mux.HandleFunc("POST /api/network/toggle", h.handleAPINetworkToggle) mux.HandleFunc("POST /api/network/confirm", h.handleAPINetworkConfirm) mux.HandleFunc("POST /api/network/rollback", h.handleAPINetworkRollback) // Export mux.HandleFunc("GET /api/export/list", h.handleAPIExportList) mux.HandleFunc("GET /api/export/usb", h.handleAPIExportUSBTargets) mux.HandleFunc("POST /api/export/usb/audit", h.handleAPIExportUSBAudit) mux.HandleFunc("POST /api/export/usb/bundle", h.handleAPIExportUSBBundle) // Tools mux.HandleFunc("GET /api/tools/check", h.handleAPIToolsCheck) // Display mux.HandleFunc("GET /api/display/resolutions", h.handleAPIDisplayResolutions) mux.HandleFunc("POST /api/display/set", h.handleAPIDisplaySet) // GPU presence / tools mux.HandleFunc("GET /api/gpu/presence", h.handleAPIGPUPresence) mux.HandleFunc("GET /api/gpu/tools", h.handleAPIGPUTools) // System mux.HandleFunc("GET /api/system/ram-status", h.handleAPIRAMStatus) mux.HandleFunc("POST /api/system/install-to-ram", h.handleAPIInstallToRAM) // Preflight mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight) // Install mux.HandleFunc("GET /api/install/disks", h.handleAPIInstallDisks) mux.HandleFunc("POST /api/install/run", h.handleAPIInstallRun) // Metrics — SSE stream of live sensor data + server-side SVG charts + CSV export mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream) mux.HandleFunc("GET /api/metrics/latest", h.handleAPIMetricsLatest) mux.HandleFunc("GET /api/metrics/chart/", h.handleMetricsChartSVG) mux.HandleFunc("GET /api/metrics/export.csv", h.handleAPIMetricsExportCSV) // Reanimator chart static assets (viewer template expects /static/*) mux.Handle("GET /static/", http.StripPrefix("/static/", web.Static())) // ── Pages ──────────────────────────────────────────────────────────────── mux.HandleFunc("GET /", h.handlePage) h.mux = mux return mux } func (h *handler) startMetricsCollector() { go func() { ticker := time.NewTicker(metricsCollectInterval) defer ticker.Stop() for range ticker.C { sample := platform.SampleLiveMetrics() if h.metricsDB != nil { _ = h.metricsDB.Write(sample) } h.feedRings(sample) h.setLatestMetric(sample) } }() } func (h *handler) setLatestMetric(sample platform.LiveMetricSample) { h.latestMu.Lock() defer h.latestMu.Unlock() cp := sample h.latest = &cp } func (h *handler) latestMetric() (platform.LiveMetricSample, bool) { h.latestMu.RLock() defer h.latestMu.RUnlock() if h.latest == nil { return platform.LiveMetricSample{}, false } return *h.latest, true } // ListenAndServe starts the HTTP server. func ListenAndServe(addr string, opts HandlerOptions) error { return http.ListenAndServe(addr, NewHandler(opts)) } // ── Infrastructure handlers ────────────────────────────────────────────────── func (h *handler) handleHealthz(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) } // ── Compatibility endpoints ────────────────────────────────────────────────── func (h *handler) handleAuditJSON(w http.ResponseWriter, r *http.Request) { data, err := loadSnapshot(h.opts.AuditPath) if err != nil { if errors.Is(err, os.ErrNotExist) { http.Error(w, "audit snapshot not found", http.StatusNotFound) return } http.Error(w, fmt.Sprintf("read audit snapshot: %v", err), http.StatusInternalServerError) return } // Re-apply SAT overlay on every request so that SAT results run after the // last audit always appear in the downloaded JSON without needing a re-audit. if overlaid, err := app.ApplySATOverlay(data); err == nil { data = overlaid } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = w.Write(data) } func (h *handler) handleRuntimeHealthJSON(w http.ResponseWriter, r *http.Request) { data, err := loadSnapshot(filepath.Join(h.opts.ExportDir, "runtime-health.json")) if err != nil { if errors.Is(err, os.ErrNotExist) { http.Error(w, "runtime health not found", http.StatusNotFound) return } http.Error(w, fmt.Sprintf("read runtime health: %v", err), http.StatusInternalServerError) return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = w.Write(data) } func (h *handler) handleSupportBundleDownload(w http.ResponseWriter, r *http.Request) { archive, err := app.BuildSupportBundle(h.opts.ExportDir) if err != nil { http.Error(w, fmt.Sprintf("build support bundle: %v", err), http.StatusInternalServerError) return } defer os.Remove(archive) w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(archive))) http.ServeFile(w, r, archive) } func (h *handler) handleExportFile(w http.ResponseWriter, r *http.Request) { rel := strings.TrimSpace(r.URL.Query().Get("path")) if rel == "" { http.Error(w, "path is required", http.StatusBadRequest) return } clean := filepath.Clean(rel) if clean == "." || strings.HasPrefix(clean, "..") { http.Error(w, "invalid path", http.StatusBadRequest) return } // Set Content-Type explicitly to avoid mime.TypeByExtension which panics on // LiveCD environments where /usr/share/mime/globs2 has an I/O read error. w.Header().Set("Content-Type", mimeByExt(filepath.Ext(clean))) http.ServeFile(w, r, filepath.Join(h.opts.ExportDir, clean)) } // mimeByExt returns a Content-Type for known extensions, falling back to // application/octet-stream. Used to avoid calling mime.TypeByExtension. func mimeByExt(ext string) string { switch strings.ToLower(ext) { case ".json": return "application/json" case ".gz": return "application/gzip" case ".tar": return "application/x-tar" case ".log", ".txt": return "text/plain; charset=utf-8" case ".html": return "text/html; charset=utf-8" case ".svg": return "image/svg+xml" default: return "application/octet-stream" } } func (h *handler) handleExportIndex(w http.ResponseWriter, r *http.Request) { body, err := renderExportIndex(h.opts.ExportDir) if err != nil { http.Error(w, fmt.Sprintf("render export index: %v", err), http.StatusInternalServerError) return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(body)) } func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) { snapshot, _ := loadSnapshot(h.opts.AuditPath) body, err := viewer.RenderHTML(snapshot, h.opts.Title) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(body) } 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 } datasets, names, labels, title, yMin, yMax, ok := h.chartDataFromDB(path) if !ok { http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable) return } buf, err := renderChartSVG(title, datasets, names, labels, yMin, yMax) 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 (h *handler) chartDataFromDB(path string) ([][]float64, []string, []string, string, *float64, *float64, bool) { samples, err := h.metricsDB.LoadAll() if err != nil || len(samples) == 0 { return nil, nil, nil, "", nil, nil, false } return chartDataFromSamples(path, samples) } func chartDataFromSamples(path string, samples []platform.LiveMetricSample) ([][]float64, []string, []string, string, *float64, *float64, bool) { var datasets [][]float64 var names []string var title string var yMin, yMax *float64 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)) for i, s := range samples { power[i] = s.PowerW } power = normalizePowerSeries(power) datasets = [][]float64{power} names = []string{"Power W"} 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 strings.HasPrefix(path, "gpu/"): rest := strings.TrimPrefix(path, "gpu/") sub := "" if i := strings.LastIndex(rest, "-"); i > 0 { sub = rest[i+1:] rest = rest[:i] } idx := 0 fmt.Sscanf(rest, "%d", &idx) switch sub { case "load": title = fmt.Sprintf("GPU %d Load", idx) 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 } datasets = [][]float64{coalesceDataset(util, len(samples)), coalesceDataset(mem, len(samples))} names = []string{"Load %", "Mem %"} yMin = floatPtr(0) yMax = floatPtr(100) case "temp": title = fmt.Sprintf("GPU %d Temperature", idx) temp := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.TempC }) if temp == nil { return nil, nil, nil, "", nil, nil, false } datasets = [][]float64{temp} names = []string{"Temp °C"} yMin = floatPtr(0) yMax = autoMax120(temp) default: title = fmt.Sprintf("GPU %d Power", idx) power := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.PowerW }) if power == nil { return nil, nil, nil, "", nil, nil, false } datasets = [][]float64{power} names = []string{"Power W"} yMin, yMax = autoBounds120(power) } default: return nil, nil, nil, "", nil, nil, false } return datasets, names, labels, title, yMin, yMax, len(datasets) > 0 } 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, fmt.Sprintf("GPU %d", 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 } 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) } // renderChartSVG renders a line chart SVG with a fixed Y-axis range. func renderChartSVG(title string, datasets [][]float64, names []string, labels []string, yMin, yMax *float64) ([]byte, error) { n := len(labels) if n == 0 { n = 1 labels = []string{""} } for i := range datasets { if len(datasets[i]) == 0 { datasets[i] = make([]float64, n) } } // Append global min/avg/max to title. mn, avg, mx := globalStats(datasets) if mx > 0 { title = fmt.Sprintf("%s ↓%s ~%s ↑%s", title, chartLegendNumber(mn), chartLegendNumber(avg), chartLegendNumber(mx), ) } title = sanitizeChartText(title) names = sanitizeChartTexts(names) sparse := sanitizeChartTexts(sparseLabels(labels, 6)) opt := gocharts.NewLineChartOptionWithData(datasets) 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{chartYAxisOption(yMin, yMax)} } // Add a single peak mark line on the series that holds the global maximum. peakIdx, _ := globalPeakSeries(datasets) if peakIdx >= 0 && peakIdx < len(opt.SeriesList) { opt.SeriesList[peakIdx].MarkLine = gocharts.NewMarkLine(gocharts.SeriesMarkTypeMax) } p := gocharts.NewPainter(gocharts.PainterOptions{ OutputFormat: gocharts.ChartOutputSVG, Width: 1400, Height: chartCanvasHeight(len(names)), }, gocharts.PainterThemeOption(gocharts.GetTheme("grafana"))) if err := p.LineChart(opt); err != nil { return nil, err } 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) { idx = -1 for i, ds := range datasets { for _, v := range ds { if v > peak { peak = v idx = i } } } return idx, peak } // 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 sanitizeChartTexts(in []string) []string { out := make([]string, len(in)) for i, s := range in { out[i] = sanitizeChartText(s) } return out } func safeIdx(s []float64, i int) float64 { if i < len(s) { return s[i] } return 0 } func snapshotNamedRings(rings []*namedMetricsRing) ([][]float64, []string, []string) { var datasets [][]float64 var names []string var labels []string for _, item := range rings { if item == nil || item.Ring == nil { continue } vals, l := item.Ring.snapshot() datasets = append(datasets, vals) names = append(names, item.Name) if len(labels) == 0 { labels = l } } 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, 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 } 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 } func (h *handler) handleAPIMetricsExportCSV(w http.ResponseWriter, r *http.Request) { if h.metricsDB == nil { http.Error(w, "metrics database not available", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="bee-metrics.csv"`) w.Header().Set("Cache-Control", "no-store") _ = h.metricsDB.ExportCSV(w) } // ── Page handler ───────────────────────────────────────────────────────────── func (h *handler) handleReady(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") if _, err := os.Stat(h.opts.AuditPath); err != nil { w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte("starting")) return } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ready")) } const loadingPageHTML = `