package webui import ( "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "strings" "sync" "time" "bee/audit/internal/app" "bee/audit/internal/runtimeenv" gocharts "github.com/go-analyze/charts" "reanimator/chart/viewer" "reanimator/chart/web" ) const defaultTitle = "Bee Hardware Audit" // HandlerOptions configures the web UI handler. type HandlerOptions struct { Title 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 labels []string size int } func newMetricsRing(size int) *metricsRing { return &metricsRing{size: size, vals: make([]float64, 0, size), labels: make([]string, 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.labels = r.labels[1:] } r.vals = append(r.vals, v) r.labels = append(r.labels, time.Now().Format("15:04")) } func (r *metricsRing) snapshot() ([]float64, []string) { r.mu.Lock() defer r.mu.Unlock() v := make([]float64, len(r.vals)) l := make([]string, len(r.labels)) copy(v, r.vals) copy(l, r.labels) 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 // server rings ringCPUTemp *metricsRing ringCPULoad *metricsRing ringMemLoad *metricsRing ringPower *metricsRing ringFans []*metricsRing fanNames []string // per-GPU rings (index = GPU index) gpuRings []*gpuRings ringsMu sync.Mutex // install job (at most one at a time) installJob *jobState installMu sync.Mutex } // 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, ringCPUTemp: newMetricsRing(120), ringCPULoad: newMetricsRing(120), ringMemLoad: newMetricsRing(120), ringPower: newMetricsRing(120), } mux := http.NewServeMux() // ── Infrastructure ────────────────────────────────────────────────────── mux.HandleFunc("GET /healthz", h.handleHealthz) // ── 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/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("GET /api/sat/stream", h.handleAPISATStream) mux.HandleFunc("POST /api/sat/abort", h.handleAPISATAbort) // 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) // Export mux.HandleFunc("GET /api/export/list", h.handleAPIExportList) mux.HandleFunc("POST /api/export/bundle", h.handleAPIExportBundle) // Tools mux.HandleFunc("GET /api/tools/check", h.handleAPIToolsCheck) // 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) mux.HandleFunc("GET /api/install/stream", h.handleAPIInstallStream) // Metrics — SSE stream of live sensor data + server-side SVG charts mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream) mux.HandleFunc("GET /api/metrics/chart/", h.handleMetricsChartSVG) // 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 } // 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 } 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 } 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 } http.ServeFile(w, r, filepath.Join(h.opts.ExportDir, clean)) } 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") 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"} default: http.NotFound(w, r) return } // Ensure all datasets same length as labels n := len(labels) if n == 0 { n = 1 labels = []string{""} } for i := range datasets { if len(datasets[i]) == 0 { datasets[i] = make([]float64, n) } } sparse := sparseLabels(labels, 6) opt := gocharts.NewLineChartOptionWithData(datasets) opt.Title = gocharts.TitleOption{Text: title} opt.XAxis.Labels = sparse opt.Legend = gocharts.LegendOption{SeriesNames: names} p := gocharts.NewPainter(gocharts.PainterOptions{ OutputFormat: gocharts.ChartOutputSVG, Width: 1400, Height: 280, }, gocharts.PainterThemeOption(gocharts.GetTheme("grafana"))) if err := p.LineChart(opt); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } buf, err := p.Bytes() 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 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) { page := strings.TrimPrefix(r.URL.Path, "/") if page == "" { page = "dashboard" } 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}) }