package webui import ( "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "strings" "bee/audit/internal/app" "bee/audit/internal/runtimeenv" ) 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 } // handler is the HTTP handler for the web UI. type handler struct { opts HandlerOptions mux *http.ServeMux } // 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} 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) // 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) // Metrics — SSE stream of live sensor data mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream) // ── 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 := renderViewerPage(h.opts.Title, snapshot) w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(body)) } // ── 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}) }