diff --git a/audit/internal/app/support_bundle.go b/audit/internal/app/support_bundle.go index ddaf5e9..12285dd 100644 --- a/audit/internal/app/support_bundle.go +++ b/audit/internal/app/support_bundle.go @@ -36,6 +36,8 @@ var supportBundleCommands = []struct { {name: "system/dmesg-tail.txt", cmd: []string{"sh", "-c", "dmesg | tail -n 200"}}, } +const supportBundleGlob = "bee-support-*.tar.gz" + func BuildSupportBundle(exportDir string) (string, error) { exportDir = strings.TrimSpace(exportDir) if exportDir == "" { @@ -86,34 +88,64 @@ func BuildSupportBundle(exportDir string) (string, error) { return archivePath, nil } +func LatestSupportBundlePath() (string, error) { + return latestSupportBundlePath(os.TempDir()) +} + func cleanupOldSupportBundles(dir string) error { - matches, err := filepath.Glob(filepath.Join(dir, "bee-support-*.tar.gz")) + matches, err := filepath.Glob(filepath.Join(dir, supportBundleGlob)) if err != nil { return err } - type entry struct { - path string - mod time.Time + entries := supportBundleEntries(matches) + for path, mod := range entries { + if time.Since(mod) > 24*time.Hour { + _ = os.Remove(path) + delete(entries, path) + } } - list := make([]entry, 0, len(matches)) + ordered := orderSupportBundles(entries) + if len(ordered) > 3 { + for _, old := range ordered[3:] { + _ = os.Remove(old) + } + } + return nil +} + +func latestSupportBundlePath(dir string) (string, error) { + matches, err := filepath.Glob(filepath.Join(dir, supportBundleGlob)) + if err != nil { + return "", err + } + ordered := orderSupportBundles(supportBundleEntries(matches)) + if len(ordered) == 0 { + return "", os.ErrNotExist + } + return ordered[0], nil +} + +func supportBundleEntries(matches []string) map[string]time.Time { + entries := make(map[string]time.Time, len(matches)) for _, match := range matches { info, err := os.Stat(match) if err != nil { continue } - if time.Since(info.ModTime()) > 24*time.Hour { - _ = os.Remove(match) - continue - } - list = append(list, entry{path: match, mod: info.ModTime()}) + entries[match] = info.ModTime() } - sort.Slice(list, func(i, j int) bool { return list[i].mod.After(list[j].mod) }) - if len(list) > 3 { - for _, old := range list[3:] { - _ = os.Remove(old.path) - } + return entries +} + +func orderSupportBundles(entries map[string]time.Time) []string { + ordered := make([]string, 0, len(entries)) + for path := range entries { + ordered = append(ordered, path) } - return nil + sort.Slice(ordered, func(i, j int) bool { + return entries[ordered[i]].After(entries[ordered[j]]) + }) + return ordered } func writeJournalDump(dst string) error { diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index 63e14fe..b0000b0 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -2,7 +2,6 @@ package webui import ( "bufio" - "context" "encoding/json" "errors" "fmt" @@ -87,15 +86,16 @@ func streamJob(w http.ResponseWriter, r *http.Request, j *jobState) { } } -// runCmdJob runs an exec.Cmd as a background job, streaming stdout+stderr lines. -func runCmdJob(j *jobState, cmd *exec.Cmd) { +// streamCmdJob runs an exec.Cmd and streams stdout+stderr lines into j. +func streamCmdJob(j *jobState, cmd *exec.Cmd) error { pr, pw := io.Pipe() cmd.Stdout = pw cmd.Stderr = pw if err := cmd.Start(); err != nil { - j.finish(err.Error()) - return + _ = pw.Close() + _ = pr.Close() + return err } // Lower the CPU scheduling priority of stress/audit subprocesses to nice+10 // so the X server and kernel interrupt handling remain responsive under load @@ -104,8 +104,10 @@ func runCmdJob(j *jobState, cmd *exec.Cmd) { _ = syscall.Setpriority(syscall.PRIO_PROCESS, cmd.Process.Pid, 10) } + scanDone := make(chan error, 1) go func() { scanner := bufio.NewScanner(pr) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { // Split on \r to handle progress-bar style output (e.g. \r overwrites) // and strip ANSI escape codes so logs are readable in the browser. @@ -117,15 +119,21 @@ func runCmdJob(j *jobState, cmd *exec.Cmd) { } } } + if err := scanner.Err(); err != nil && !errors.Is(err, io.ErrClosedPipe) { + scanDone <- err + return + } + scanDone <- nil }() err := cmd.Wait() _ = pw.Close() + scanErr := <-scanDone + _ = pr.Close() if err != nil { - j.finish(err.Error()) - } else { - j.finish("") + return err } + return scanErr } // ── Audit ───────────────────────────────────────────────────────────────────── @@ -417,15 +425,23 @@ func (h *handler) handleAPIExportList(w http.ResponseWriter, r *http.Request) { } 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()) + if globalQueue.hasActiveTarget("support-bundle") { + writeError(w, http.StatusConflict, "support bundle task is already pending or running") return } + t := &Task{ + ID: newJobID("support-bundle"), + Name: "Support Bundle", + Target: "support-bundle", + Status: TaskPending, + CreatedAt: time.Now(), + } + globalQueue.enqueue(t) writeJSON(w, map[string]string{ - "status": "ok", - "path": archive, - "url": "/export/support.tar.gz", + "status": "queued", + "task_id": t.ID, + "job_id": t.ID, + "url": "/export/support.tar.gz", }) } @@ -513,10 +529,7 @@ func (h *handler) handleAPIInstallToRAM(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusServiceUnavailable, "app not configured") return } - h.installMu.Lock() - installRunning := h.installJob != nil && !h.installJob.isDone() - h.installMu.Unlock() - if installRunning { + if globalQueue.hasActiveTarget("install") { writeError(w, http.StatusConflict, "install to disk is already running") return } @@ -631,35 +644,23 @@ func (h *handler) handleAPIInstallRun(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusConflict, "install to RAM task is already pending or running") return } - - h.installMu.Lock() - if h.installJob != nil && !h.installJob.isDone() { - h.installMu.Unlock() - writeError(w, http.StatusConflict, "install already running") + if globalQueue.hasActiveTarget("install") { + writeError(w, http.StatusConflict, "install task is already pending or running") return } - j := &jobState{} - h.installJob = j - h.installMu.Unlock() - - logFile := platform.InstallLogPath(req.Device) - go runCmdJob(j, exec.CommandContext(context.Background(), "bee-install", req.Device, logFile)) - - w.WriteHeader(http.StatusNoContent) -} - -func (h *handler) handleAPIInstallStream(w http.ResponseWriter, r *http.Request) { - h.installMu.Lock() - j := h.installJob - h.installMu.Unlock() - if j == nil { - if !sseStart(w) { - return - } - sseWrite(w, "done", "") - return + t := &Task{ + ID: newJobID("install"), + Name: "Install to Disk", + Target: "install", + Priority: 20, + Status: TaskPending, + CreatedAt: time.Now(), + params: taskParams{ + Device: req.Device, + }, } - streamJob(w, r, j) + globalQueue.enqueue(t) + writeJSON(w, map[string]string{"task_id": t.ID, "job_id": t.ID}) } // ── Metrics SSE ─────────────────────────────────────────────────────────────── diff --git a/audit/internal/webui/api_test.go b/audit/internal/webui/api_test.go index f950cce..d508073 100644 --- a/audit/internal/webui/api_test.go +++ b/audit/internal/webui/api_test.go @@ -1,6 +1,7 @@ package webui import ( + "encoding/json" "net/http/httptest" "strings" "testing" @@ -62,3 +63,40 @@ func TestHandleAPISATRunDecodesBodyWithoutContentLength(t *testing.T) { t.Fatalf("burn profile=%q want smoke", got) } } + +func TestHandleAPIExportBundleQueuesTask(t *testing.T) { + globalQueue.mu.Lock() + originalTasks := globalQueue.tasks + globalQueue.tasks = nil + globalQueue.mu.Unlock() + t.Cleanup(func() { + globalQueue.mu.Lock() + globalQueue.tasks = originalTasks + globalQueue.mu.Unlock() + }) + + h := &handler{opts: HandlerOptions{ExportDir: t.TempDir()}} + req := httptest.NewRequest("POST", "/api/export/bundle", nil) + rec := httptest.NewRecorder() + + h.handleAPIExportBundle(rec, req) + + if rec.Code != 200 { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body["task_id"] == "" { + t.Fatalf("missing task_id in response: %v", body) + } + globalQueue.mu.Lock() + defer globalQueue.mu.Unlock() + if len(globalQueue.tasks) != 1 { + t.Fatalf("tasks=%d want 1", len(globalQueue.tasks)) + } + if got := globalQueue.tasks[0].Target; got != "support-bundle" { + t.Fatalf("target=%q want support-bundle", got) + } +} diff --git a/audit/internal/webui/metricsdb.go b/audit/internal/webui/metricsdb.go index 704ffb2..1e31280 100644 --- a/audit/internal/webui/metricsdb.go +++ b/audit/internal/webui/metricsdb.go @@ -4,6 +4,8 @@ import ( "database/sql" "encoding/csv" "io" + "os" + "path/filepath" "strconv" "time" @@ -20,6 +22,9 @@ type MetricsDB struct { // openMetricsDB opens (or creates) the metrics database at the given path. func openMetricsDB(path string) (*MetricsDB, error) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, err + } db, err := sql.Open("sqlite", path+"?_journal=WAL&_busy_timeout=5000") if err != nil { return nil, err @@ -132,7 +137,7 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri defer rows.Close() type sysRow struct { - ts int64 + ts int64 cpu, mem, pwr float64 } var sysRows []sysRow @@ -156,7 +161,10 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri maxTS := sysRows[len(sysRows)-1].ts // Load GPU rows in range - type gpuKey struct{ ts int64; idx int } + type gpuKey struct { + ts int64 + idx int + } gpuData := map[gpuKey]platform.GPUMetricRow{} gRows, err := m.db.Query( `SELECT ts,gpu_index,temp_c,usage_pct,mem_usage_pct,power_w FROM gpu_metrics WHERE ts>=? AND ts<=? ORDER BY ts,gpu_index`, @@ -174,7 +182,10 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri } // Load fan rows in range - type fanKey struct{ ts int64; name string } + type fanKey struct { + ts int64 + name string + } fanData := map[fanKey]float64{} fRows, err := m.db.Query( `SELECT ts,name,rpm FROM fan_metrics WHERE ts>=? AND ts<=?`, minTS, maxTS, @@ -192,7 +203,10 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri } // Load temp rows in range - type tempKey struct{ ts int64; name string } + type tempKey struct { + ts int64 + name string + } tempData := map[tempKey]platform.TempReading{} tRows, err := m.db.Query( `SELECT ts,name,grp,celsius FROM temp_metrics WHERE ts>=? AND ts<=?`, minTS, maxTS, diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index 1f29548..4d0c220 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -926,7 +926,7 @@ func renderExport(exportDir string) string { return `
Creates a tar.gz archive of all audit files, SAT results, and logs.
-↓ Download Support Bundle +` + renderSupportBundleInline() + `| File |
|---|
Downloads a tar.gz archive of all audit files, SAT results, and logs.
-↓ Download Support Bundle +` + renderSupportBundleInline() + `