fix(webui): build support bundle synchronously on download, bypass task queue
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 <noreply@anthropic.com>
This commit is contained in:
@@ -428,26 +428,6 @@ func (h *handler) handleAPIExportList(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, entries)
|
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) {
|
func (h *handler) handleAPIExportUSBTargets(w http.ResponseWriter, _ *http.Request) {
|
||||||
if h.opts.App == nil {
|
if h.opts.App == nil {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package webui
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestPushFanRingsTracksByNameAndCarriesForwardMissingSamples(t *testing.T) {
|
||||||
h := &handler{}
|
h := &handler{}
|
||||||
|
|||||||
@@ -1178,73 +1178,46 @@ func listExportFiles(exportDir string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderSupportBundleInline() string {
|
func renderSupportBundleInline() string {
|
||||||
return `<button id="support-bundle-btn" class="btn btn-primary" onclick="supportBundleBuild()">Build Support Bundle</button>
|
return `<button id="support-bundle-btn" class="btn btn-primary" onclick="supportBundleDownload()">↓ Download Support Bundle</button>
|
||||||
<a id="support-bundle-download" class="btn btn-secondary" href="/export/support.tar.gz" style="display:none">↓ Download Support Bundle</a>
|
<div id="support-bundle-status" style="margin-top:10px;font-size:13px;color:var(--muted)"></div>
|
||||||
<div id="support-bundle-status" style="margin-top:12px;font-size:13px;color:var(--muted)">No support bundle built in this session.</div>
|
|
||||||
<div id="support-bundle-log" class="terminal" style="display:none;margin-top:12px;max-height:260px"></div>
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
window.supportBundleDownload = function() {
|
||||||
var _supportBundleES = null;
|
|
||||||
window.supportBundleBuild = function() {
|
|
||||||
var btn = document.getElementById('support-bundle-btn');
|
var btn = document.getElementById('support-bundle-btn');
|
||||||
var status = document.getElementById('support-bundle-status');
|
var status = document.getElementById('support-bundle-status');
|
||||||
var log = document.getElementById('support-bundle-log');
|
|
||||||
var download = document.getElementById('support-bundle-download');
|
|
||||||
if (_supportBundleES) {
|
|
||||||
_supportBundleES.close();
|
|
||||||
_supportBundleES = null;
|
|
||||||
}
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Building...';
|
btn.textContent = 'Building...';
|
||||||
status.textContent = 'Queueing support bundle task...';
|
status.textContent = 'Collecting logs and export data\u2026';
|
||||||
status.style.color = 'var(--muted)';
|
status.style.color = 'var(--muted)';
|
||||||
log.style.display = '';
|
var filename = 'bee-support.tar.gz';
|
||||||
log.textContent = '';
|
fetch('/export/support.tar.gz')
|
||||||
download.style.display = 'none';
|
.then(function(r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
fetch('/api/export/bundle', {method:'POST'}).then(function(r){
|
var cd = r.headers.get('Content-Disposition') || '';
|
||||||
return r.json().then(function(j){
|
var m = cd.match(/filename="?([^";]+)"?/);
|
||||||
if (!r.ok) throw new Error(j.error || r.statusText);
|
if (m) filename = m[1];
|
||||||
return j;
|
return r.blob();
|
||||||
});
|
})
|
||||||
}).then(function(data){
|
.then(function(blob) {
|
||||||
if (!data.task_id) throw new Error('missing task id');
|
var url = URL.createObjectURL(blob);
|
||||||
status.textContent = 'Building support bundle...';
|
var a = document.createElement('a');
|
||||||
_supportBundleES = new EventSource('/api/tasks/' + data.task_id + '/stream');
|
a.href = url;
|
||||||
_supportBundleES.onmessage = function(e) {
|
a.download = filename;
|
||||||
log.textContent += e.data + '\n';
|
document.body.appendChild(a);
|
||||||
log.scrollTop = log.scrollHeight;
|
a.click();
|
||||||
};
|
document.body.removeChild(a);
|
||||||
_supportBundleES.addEventListener('done', function(e) {
|
URL.revokeObjectURL(url);
|
||||||
_supportBundleES.close();
|
status.textContent = 'Download started.';
|
||||||
_supportBundleES = null;
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Build Support Bundle';
|
|
||||||
if (e.data) {
|
|
||||||
status.textContent = 'Error: ' + e.data;
|
|
||||||
status.style.color = 'var(--crit-fg)';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
status.textContent = 'Support bundle ready.';
|
|
||||||
status.style.color = 'var(--ok-fg)';
|
status.style.color = 'var(--ok-fg)';
|
||||||
download.style.display = '';
|
})
|
||||||
});
|
.catch(function(e) {
|
||||||
_supportBundleES.onerror = function() {
|
status.textContent = 'Error: ' + e.message;
|
||||||
if (_supportBundleES) _supportBundleES.close();
|
|
||||||
_supportBundleES = null;
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Build Support Bundle';
|
|
||||||
status.textContent = 'Support bundle stream disconnected.';
|
|
||||||
status.style.color = 'var(--crit-fg)';
|
status.style.color = 'var(--crit-fg)';
|
||||||
};
|
})
|
||||||
}).catch(function(e){
|
.finally(function() {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Build Support Bundle';
|
btn.textContent = '\u2195 Download Support Bundle';
|
||||||
status.textContent = 'Error: ' + e;
|
|
||||||
status.style.color = 'var(--crit-fg)';
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
})();
|
|
||||||
</script>`
|
</script>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -261,7 +261,6 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
|
|
||||||
// Export
|
// Export
|
||||||
mux.HandleFunc("GET /api/export/list", h.handleAPIExportList)
|
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("GET /api/export/usb", h.handleAPIExportUSBTargets)
|
||||||
mux.HandleFunc("POST /api/export/usb/audit", h.handleAPIExportUSBAudit)
|
mux.HandleFunc("POST /api/export/usb/audit", h.handleAPIExportUSBAudit)
|
||||||
mux.HandleFunc("POST /api/export/usb/bundle", h.handleAPIExportUSBBundle)
|
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) {
|
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 err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
http.Error(w, fmt.Sprintf("build support bundle: %v", err), http.StatusInternalServerError)
|
||||||
http.Error(w, "support bundle not built yet", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Error(w, fmt.Sprintf("locate support bundle: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer os.Remove(archive)
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("Content-Type", "application/gzip")
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(archive)))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(archive)))
|
||||||
|
|||||||
Reference in New Issue
Block a user