package webui import ( "bufio" "encoding/json" "errors" "fmt" "html" "io" "log/slog" "mime" "net" "net/http" "os" "path/filepath" "runtime/debug" "sort" "strings" "sync" "time" "bee/audit/internal/app" "bee/audit/internal/platform" "bee/audit/internal/runtimeenv" "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) mux.HandleFunc("GET /loading", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(loadingPageHTML)) }) // ── 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-targeted-stress/run", h.handleAPISATRun("nvidia-targeted-stress")) mux.HandleFunc("POST /api/sat/nvidia-compute/run", h.handleAPISATRun("nvidia-compute")) mux.HandleFunc("POST /api/sat/nvidia-targeted-power/run", h.handleAPISATRun("nvidia-targeted-power")) mux.HandleFunc("POST /api/sat/nvidia-pulse/run", h.handleAPISATRun("nvidia-pulse")) mux.HandleFunc("POST /api/sat/nvidia-interconnect/run", h.handleAPISATRun("nvidia-interconnect")) mux.HandleFunc("POST /api/sat/nvidia-bandwidth/run", h.handleAPISATRun("nvidia-bandwidth")) 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) mux.HandleFunc("POST /api/benchmark/nvidia/run", h.handleAPIBenchmarkNvidiaRun) // 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) mux.HandleFunc("GET /api/tasks/{id}/charts", h.handleAPITaskChartsIndex) mux.HandleFunc("GET /api/tasks/{id}/chart/", h.handleAPITaskChartSVG) mux.HandleFunc("GET /tasks/{id}", h.handleTaskPage) // 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/nvidia", h.handleAPIGNVIDIAGPUs) mux.HandleFunc("GET /api/gpu/nvidia-status", h.handleAPIGNVIDIAGPUStatuses) mux.HandleFunc("POST /api/gpu/nvidia-reset", h.handleAPIGNVIDIAReset) 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 recoverMiddleware(mux) } func (h *handler) startMetricsCollector() { goRecoverLoop("metrics collector", 2*time.Second, 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 { srv := &http.Server{ Addr: addr, Handler: NewHandler(opts), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, IdleTimeout: 2 * time.Minute, } return srv.ListenAndServe() } type trackingResponseWriter struct { http.ResponseWriter wroteHeader bool } func (w *trackingResponseWriter) WriteHeader(statusCode int) { w.wroteHeader = true w.ResponseWriter.WriteHeader(statusCode) } func (w *trackingResponseWriter) Write(p []byte) (int, error) { w.wroteHeader = true return w.ResponseWriter.Write(p) } func (w *trackingResponseWriter) Flush() { w.wroteHeader = true if f, ok := w.ResponseWriter.(http.Flusher); ok { f.Flush() } } func (w *trackingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { h, ok := w.ResponseWriter.(http.Hijacker) if !ok { return nil, nil, fmt.Errorf("hijacking not supported") } return h.Hijack() } func (w *trackingResponseWriter) Push(target string, opts *http.PushOptions) error { p, ok := w.ResponseWriter.(http.Pusher) if !ok { return http.ErrNotSupported } return p.Push(target, opts) } func (w *trackingResponseWriter) ReadFrom(r io.Reader) (int64, error) { rf, ok := w.ResponseWriter.(io.ReaderFrom) if !ok { return io.Copy(w.ResponseWriter, r) } w.wroteHeader = true return rf.ReadFrom(r) } func recoverMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tw := &trackingResponseWriter{ResponseWriter: w} defer func() { if rec := recover(); rec != nil { slog.Error("http handler panic", "method", r.Method, "path", r.URL.Path, "panic", fmt.Sprint(rec), "stack", string(debug.Stack()), ) if !tw.wroteHeader { http.Error(tw, "internal server error", http.StatusInternalServerError) } } }() next.ServeHTTP(tw, r) }) } // ── 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 } samples, err := h.metricsDB.LoadAll() if err != nil || len(samples) == 0 { http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable) return } timeline := metricsTimelineSegments(samples, time.Now()) if idx, sub, ok := parseGPUChartPath(path); ok && sub == "overview" { buf, ok, err := renderGPUOverviewChartSVG(idx, samples, timeline) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if !ok { http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(buf) return } datasets, names, labels, title, yMin, yMax, ok := chartDataFromSamples(path, samples) if !ok { http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable) return } buf, err := renderMetricChartSVG( title, labels, sampleTimes(samples), datasets, names, yMin, yMax, chartCanvasHeightForPath(path, len(names)), timeline, ) 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 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 path == "gpu-all-clock": title = "GPU Core Clock" datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.ClockMHz }) yMin, yMax = autoBounds120(datasets...) case path == "gpu-all-memclock": title = "GPU Memory Clock" datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.MemClockMHz }) yMin, yMax = autoBounds120(datasets...) case strings.HasPrefix(path, "gpu/"): idx, sub, ok := parseGPUChartPath(path) if !ok { return nil, nil, nil, "", nil, nil, false } switch sub { case "load": title = gpuDisplayLabel(idx) + " Load" 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 = gpuDisplayLabel(idx) + " Temperature" 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) case "clock": title = gpuDisplayLabel(idx) + " Core Clock" clock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.ClockMHz }) if clock == nil { return nil, nil, nil, "", nil, nil, false } datasets = [][]float64{clock} names = []string{"Core Clock MHz"} yMin, yMax = autoBounds120(clock) case "memclock": title = gpuDisplayLabel(idx) + " Memory Clock" clock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.MemClockMHz }) if clock == nil { return nil, nil, nil, "", nil, nil, false } datasets = [][]float64{clock} names = []string{"Memory Clock MHz"} yMin, yMax = autoBounds120(clock) default: title = gpuDisplayLabel(idx) + " Power" 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 parseGPUChartPath(path string) (idx int, sub string, ok bool) { if !strings.HasPrefix(path, "gpu/") { return 0, "", false } rest := strings.TrimPrefix(path, "gpu/") if rest == "" { return 0, "", false } sub = "" if i := strings.LastIndex(rest, "-"); i > 0 { sub = rest[i+1:] rest = rest[:i] } n, err := fmt.Sscanf(rest, "%d", &idx) if err != nil || n != 1 { return 0, "", false } return idx, sub, true } 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, gpuDisplayLabel(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) } func gpuChartLabelIndices(total, target int) []int { if total <= 0 { return nil } if total == 1 { return []int{0} } step := total / target if step < 1 { step = 1 } var indices []int for i := 0; i < total; i += step { indices = append(indices, i) } if indices[len(indices)-1] != total-1 { indices = append(indices, total-1) } return indices } func chartCanvasHeightForPath(path string, seriesCount int) int { height := chartCanvasHeight(seriesCount) if isGPUChartPath(path) { return height * 2 } return height } func isGPUChartPath(path string) bool { return strings.HasPrefix(path, "gpu-all-") || strings.HasPrefix(path, "gpu/") } func chartLegendVisible(seriesCount int) bool { return seriesCount <= 8 } func chartCanvasHeight(seriesCount int) int { if chartLegendVisible(seriesCount) { return 360 } return 288 } // 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 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 (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 strings.TrimSpace(h.opts.AuditPath) == "" { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ready")) return } 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 = ` EASY-BEE — Starting
Hardware Audit LiveCD
Connecting to bee-web...
` func (h *handler) handlePage(w http.ResponseWriter, r *http.Request) { page := strings.TrimPrefix(r.URL.Path, "/") if page == "" { page = "dashboard" } // Redirect old routes to new names switch page { case "tests": http.Redirect(w, r, "/validate", http.StatusMovedPermanently) return case "burn-in": http.Redirect(w, r, "/burn", http.StatusMovedPermanently) return } body := renderPage(page, h.opts) w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(body)) } // ── Helpers ────────────────────────────────────────────────────────────────── func loadSnapshot(path string) ([]byte, error) { if strings.TrimSpace(path) == "" { return nil, os.ErrNotExist } return os.ReadFile(path) } // writeJSON sends v as JSON with status 200. func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(v) } // writeError sends a JSON error response. func writeError(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) }