package webui import ( "bufio" "context" "encoding/json" "fmt" "io" "net/http" "os/exec" "path/filepath" "strings" "sync/atomic" "time" "bee/audit/internal/app" "bee/audit/internal/platform" ) // ── Job ID counter ──────────────────────────────────────────────────────────── var jobCounter atomic.Uint64 func newJobID(prefix string) string { return fmt.Sprintf("%s-%d", prefix, jobCounter.Add(1)) } // ── SSE helpers ─────────────────────────────────────────────────────────────── func sseWrite(w http.ResponseWriter, event, data string) bool { f, ok := w.(http.Flusher) if !ok { return false } if event != "" { fmt.Fprintf(w, "event: %s\n", event) } fmt.Fprintf(w, "data: %s\n\n", data) f.Flush() return true } func sseStart(w http.ResponseWriter) bool { _, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming not supported", http.StatusInternalServerError) return false } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") return true } // streamJob streams lines from a jobState to a SSE response. func streamJob(w http.ResponseWriter, r *http.Request, j *jobState) { if !sseStart(w) { return } existing, ch := j.subscribe() for _, line := range existing { sseWrite(w, "", line) } if ch == nil { // Job already finished sseWrite(w, "done", j.err) return } for { select { case line, ok := <-ch: if !ok { sseWrite(w, "done", j.err) return } sseWrite(w, "", line) case <-r.Context().Done(): return } } } // runCmdJob runs an exec.Cmd as a background job, streaming stdout+stderr lines. func runCmdJob(j *jobState, cmd *exec.Cmd) { pr, pw := io.Pipe() cmd.Stdout = pw cmd.Stderr = pw if err := cmd.Start(); err != nil { j.finish(err.Error()) return } go func() { scanner := bufio.NewScanner(pr) for scanner.Scan() { j.append(scanner.Text()) } }() err := cmd.Wait() _ = pw.Close() if err != nil { j.finish(err.Error()) } else { j.finish("") } } // ── Audit ───────────────────────────────────────────────────────────────────── func (h *handler) handleAPIAuditRun(w http.ResponseWriter, r *http.Request) { if h.opts.App == nil { writeError(w, http.StatusServiceUnavailable, "app not configured") return } id := newJobID("audit") j := globalJobs.create(id) go func() { j.append("Running audit...") result, err := h.opts.App.RunAuditNow(h.opts.RuntimeMode) if err != nil { j.append("ERROR: " + err.Error()) j.finish(err.Error()) return } for _, line := range strings.Split(result.Body, "\n") { if line != "" { j.append(line) } } j.finish("") }() writeJSON(w, map[string]string{"job_id": id}) } func (h *handler) handleAPIAuditStream(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("job_id") j, ok := globalJobs.get(id) if !ok { http.Error(w, "job not found", http.StatusNotFound) return } streamJob(w, r, j) } // ── SAT ─────────────────────────────────────────────────────────────────────── func (h *handler) handleAPISATRun(target string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if h.opts.App == nil { writeError(w, http.StatusServiceUnavailable, "app not configured") return } id := newJobID("sat-" + target) j := globalJobs.create(id) go func() { j.append(fmt.Sprintf("Starting %s acceptance test...", target)) var ( archive string err error ) // Parse optional parameters var body struct { Duration int `json:"duration"` DiagLevel int `json:"diag_level"` GPUIndices []int `json:"gpu_indices"` } body.DiagLevel = 1 if r.ContentLength > 0 { _ = json.NewDecoder(r.Body).Decode(&body) } switch target { case "nvidia": if len(body.GPUIndices) > 0 || body.DiagLevel > 0 { result, e := h.opts.App.RunNvidiaAcceptancePackWithOptions( context.Background(), "", body.DiagLevel, body.GPUIndices, ) if e != nil { err = e } else { archive = result.Body } } else { archive, err = h.opts.App.RunNvidiaAcceptancePack("") } case "memory": archive, err = h.opts.App.RunMemoryAcceptancePack("") case "storage": archive, err = h.opts.App.RunStorageAcceptancePack("") case "cpu": dur := body.Duration if dur <= 0 { dur = 60 } archive, err = h.opts.App.RunCPUAcceptancePack("", dur) } if err != nil { j.append("ERROR: " + err.Error()) j.finish(err.Error()) return } j.append(fmt.Sprintf("Archive written: %s", archive)) j.finish("") }() writeJSON(w, map[string]string{"job_id": id}) } } func (h *handler) handleAPISATStream(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("job_id") j, ok := globalJobs.get(id) if !ok { http.Error(w, "job not found", http.StatusNotFound) return } streamJob(w, r, j) } // ── Services ────────────────────────────────────────────────────────────────── func (h *handler) handleAPIServicesList(w http.ResponseWriter, r *http.Request) { if h.opts.App == nil { writeError(w, http.StatusServiceUnavailable, "app not configured") return } names, err := h.opts.App.ListBeeServices() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } type serviceInfo struct { Name string `json:"name"` State string `json:"state"` Body string `json:"body"` } result := make([]serviceInfo, 0, len(names)) for _, name := range names { state := h.opts.App.ServiceState(name) body, _ := h.opts.App.ServiceStatus(name) result = append(result, serviceInfo{Name: name, State: state, Body: body}) } writeJSON(w, result) } func (h *handler) handleAPIServicesAction(w http.ResponseWriter, r *http.Request) { if h.opts.App == nil { writeError(w, http.StatusServiceUnavailable, "app not configured") return } var req struct { Name string `json:"name"` Action string `json:"action"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } var action platform.ServiceAction switch req.Action { case "start": action = platform.ServiceStart case "stop": action = platform.ServiceStop case "restart": action = platform.ServiceRestart default: writeError(w, http.StatusBadRequest, "action must be start|stop|restart") return } result, err := h.opts.App.ServiceActionResult(req.Name, action) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, map[string]string{"status": "ok", "output": result.Body}) } // ── Network ─────────────────────────────────────────────────────────────────── func (h *handler) handleAPINetworkStatus(w http.ResponseWriter, r *http.Request) { if h.opts.App == nil { writeError(w, http.StatusServiceUnavailable, "app not configured") return } ifaces, err := h.opts.App.ListInterfaces() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, map[string]any{ "interfaces": ifaces, "default_route": h.opts.App.DefaultRoute(), }) } func (h *handler) handleAPINetworkDHCP(w http.ResponseWriter, r *http.Request) { if h.opts.App == nil { writeError(w, http.StatusServiceUnavailable, "app not configured") return } var req struct { Interface string `json:"interface"` } _ = json.NewDecoder(r.Body).Decode(&req) var result app.ActionResult var err error if req.Interface == "" || req.Interface == "all" { result, err = h.opts.App.DHCPAllResult() } else { result, err = h.opts.App.DHCPOneResult(req.Interface) } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, map[string]string{"status": "ok", "output": result.Body}) } func (h *handler) handleAPINetworkStatic(w http.ResponseWriter, r *http.Request) { if h.opts.App == nil { writeError(w, http.StatusServiceUnavailable, "app not configured") return } var req struct { Interface string `json:"interface"` Address string `json:"address"` Prefix string `json:"prefix"` Gateway string `json:"gateway"` DNS []string `json:"dns"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } cfg := platform.StaticIPv4Config{ Interface: req.Interface, Address: req.Address, Prefix: req.Prefix, Gateway: req.Gateway, DNS: req.DNS, } result, err := h.opts.App.SetStaticIPv4Result(cfg) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, map[string]string{"status": "ok", "output": result.Body}) } // ── Export ──────────────────────────────────────────────────────────────────── func (h *handler) handleAPIExportList(w http.ResponseWriter, r *http.Request) { entries, err := listExportFiles(h.opts.ExportDir) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, entries) } func (h *handler) handleAPIExportBundle(w http.ResponseWriter, r *http.Request) { archive, err := app.BuildSupportBundle(h.opts.ExportDir) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, map[string]string{ "status": "ok", "path": archive, "url": "/export/support.tar.gz", }) } // ── Tools ───────────────────────────────────────────────────────────────────── var standardTools = []string{ "dmidecode", "smartctl", "nvme", "lspci", "ipmitool", "nvidia-smi", "memtester", "stress-ng", "nvtop", "mstflint", "qrencode", } func (h *handler) handleAPIToolsCheck(w http.ResponseWriter, r *http.Request) { if h.opts.App == nil { writeError(w, http.StatusServiceUnavailable, "app not configured") return } statuses := h.opts.App.CheckTools(standardTools) writeJSON(w, statuses) } // ── Preflight ───────────────────────────────────────────────────────────────── func (h *handler) handleAPIPreflight(w http.ResponseWriter, r *http.Request) { data, err := loadSnapshot(filepath.Join(h.opts.ExportDir, "runtime-health.json")) if err != nil { writeError(w, http.StatusNotFound, "runtime health not found") return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(data) } // ── Metrics SSE ─────────────────────────────────────────────────────────────── func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request) { if !sseStart(w) { return } ticker := time.NewTicker(time.Second) defer ticker.Stop() for { select { case <-r.Context().Done(): return case <-ticker.C: sample := platform.SampleLiveMetrics() b, err := json.Marshal(sample) if err != nil { continue } if !sseWrite(w, "metrics", string(b)) { return } } } }