From fc7fe0b08eb470e432f276a877b844b0e87a19d4 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Thu, 2 Apr 2026 12:58:00 +0300 Subject: [PATCH] fix(webui): build support bundle synchronously on download, bypass task queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support bundle is now built on-the-fly when the user clicks the button, regardless of whether other tasks are running: - GET /export/support.tar.gz builds the bundle synchronously and streams it directly to the client; the temp archive is removed after serving - Remove POST /api/export/bundle and handleAPIExportBundle — the task-queue approach meant the bundle could only be downloaded after navigating away and back, and was blocked entirely while a long SAT test was running - UI: single "Download Support Bundle" button; fetch+blob gives a loading state ("Building...") while the server collects logs, then triggers the browser download with the correct filename from Content-Disposition Co-Authored-By: Claude Sonnet 4.6 --- audit/internal/webui/api.go | 20 ------- audit/internal/webui/api_test.go | 37 ------------- audit/internal/webui/pages.go | 89 +++++++++++--------------------- audit/internal/webui/server.go | 10 ++-- 4 files changed, 34 insertions(+), 122 deletions(-) diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index 1b264d3..5fb23fc 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -428,26 +428,6 @@ func (h *handler) handleAPIExportList(w http.ResponseWriter, r *http.Request) { writeJSON(w, entries) } -func (h *handler) handleAPIExportBundle(w http.ResponseWriter, r *http.Request) { - 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": "queued", - "task_id": t.ID, - "job_id": t.ID, - "url": "/export/support.tar.gz", - }) -} func (h *handler) handleAPIExportUSBTargets(w http.ResponseWriter, _ *http.Request) { if h.opts.App == nil { diff --git a/audit/internal/webui/api_test.go b/audit/internal/webui/api_test.go index 3a15f25..d324204 100644 --- a/audit/internal/webui/api_test.go +++ b/audit/internal/webui/api_test.go @@ -1,7 +1,6 @@ package webui import ( - "encoding/json" "net/http/httptest" "strings" "testing" @@ -65,42 +64,6 @@ func TestHandleAPISATRunDecodesBodyWithoutContentLength(t *testing.T) { } } -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) - } -} func TestPushFanRingsTracksByNameAndCarriesForwardMissingSamples(t *testing.T) { h := &handler{} diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index 136ee2f..7818fc3 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -1178,73 +1178,46 @@ func listExportFiles(exportDir string) ([]string, error) { } func renderSupportBundleInline() string { - return ` - -
No support bundle built in this session.
- + return ` +
` } diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go index d9d022b..d33d38d 100644 --- a/audit/internal/webui/server.go +++ b/audit/internal/webui/server.go @@ -261,7 +261,6 @@ func NewHandler(opts HandlerOptions) http.Handler { // Export mux.HandleFunc("GET /api/export/list", h.handleAPIExportList) - mux.HandleFunc("POST /api/export/bundle", h.handleAPIExportBundle) 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) @@ -386,15 +385,12 @@ func (h *handler) handleRuntimeHealthJSON(w http.ResponseWriter, r *http.Request } func (h *handler) handleSupportBundleDownload(w http.ResponseWriter, r *http.Request) { - archive, err := app.LatestSupportBundlePath() + archive, err := app.BuildSupportBundle(h.opts.ExportDir) if err != nil { - if errors.Is(err, os.ErrNotExist) { - http.Error(w, "support bundle not built yet", http.StatusNotFound) - return - } - http.Error(w, fmt.Sprintf("locate support bundle: %v", err), http.StatusInternalServerError) + 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)))