diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go
index 0ad7835..bc7adae 100644
--- a/audit/internal/webui/pages.go
+++ b/audit/internal/webui/pages.go
@@ -2272,6 +2272,7 @@ function usbRefresh() {
document.getElementById('usb-targets').innerHTML = '';
document.getElementById('usb-msg').textContent = '';
fetch('/api/export/usb').then(r=>r.json()).then(targets => {
+ window._usbTargets = Array.isArray(targets) ? targets : [];
const st = document.getElementById('usb-status');
const ct = document.getElementById('usb-targets');
if (!targets || targets.length === 0) {
@@ -2280,7 +2281,7 @@ function usbRefresh() {
}
st.textContent = targets.length + ' device(s) found:';
ct.innerHTML = '
Device FS Size Label Model Actions ' +
- targets.map(t => {
+ targets.map((t, idx) => {
const dev = t.device || '';
const label = t.label || '';
const model = t.model || '';
@@ -2291,8 +2292,8 @@ function usbRefresh() {
''+label+' ' +
''+model+' ' +
'' +
- 'Audit JSON ' +
- 'Support Bundle ' +
+ 'Audit JSON ' +
+ 'Support Bundle ' +
'
' +
' ';
}).join('') + '
';
@@ -2300,7 +2301,14 @@ function usbRefresh() {
document.getElementById('usb-status').textContent = 'Error: ' + e;
});
}
-window.usbExport = function(type, target, btn) {
+window.usbExport = function(type, targetIndex, btn) {
+ const target = (window._usbTargets || [])[targetIndex];
+ if (!target) {
+ const msg = document.getElementById('usb-msg');
+ msg.style.color = 'var(--err,red)';
+ msg.textContent = 'Error: USB target not found. Refresh and try again.';
+ return;
+ }
const msg = document.getElementById('usb-msg');
const row = btn ? btn.closest('td') : null;
const rowMsg = row ? row.querySelector('.usb-row-msg') : null;
diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go
index 46bda52..2d2fc2e 100644
--- a/audit/internal/webui/server.go
+++ b/audit/internal/webui/server.go
@@ -270,6 +270,8 @@ func NewHandler(opts HandlerOptions) http.Handler {
mux.HandleFunc("POST /api/tasks/{id}/cancel", h.handleAPITasksCancel)
mux.HandleFunc("POST /api/tasks/{id}/priority", h.handleAPITasksPriority)
mux.HandleFunc("GET /api/tasks/{id}/stream", h.handleAPITasksStream)
+ mux.HandleFunc("GET /api/tasks/{id}/charts", h.handleAPITaskChartsIndex)
+ mux.HandleFunc("GET /api/tasks/{id}/chart/", h.handleAPITaskChartSVG)
mux.HandleFunc("GET /tasks/{id}", h.handleTaskPage)
// Services
diff --git a/audit/internal/webui/server_test.go b/audit/internal/webui/server_test.go
index 9a74d6b..7e5a3a5 100644
--- a/audit/internal/webui/server_test.go
+++ b/audit/internal/webui/server_test.go
@@ -723,6 +723,111 @@ func TestTaskDetailPageRendersSavedReport(t *testing.T) {
}
}
+func TestTaskDetailPageRendersCancelForRunningTask(t *testing.T) {
+ globalQueue.mu.Lock()
+ origTasks := globalQueue.tasks
+ globalQueue.tasks = []*Task{{
+ ID: "task-live-1",
+ Name: "CPU SAT",
+ Target: "cpu",
+ Status: TaskRunning,
+ CreatedAt: time.Now(),
+ }}
+ globalQueue.mu.Unlock()
+ t.Cleanup(func() {
+ globalQueue.mu.Lock()
+ globalQueue.tasks = origTasks
+ globalQueue.mu.Unlock()
+ })
+
+ handler := NewHandler(HandlerOptions{Title: "Bee Hardware Audit"})
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/tasks/task-live-1", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, `Cancel`) {
+ t.Fatalf("task detail page missing cancel button: %s", body)
+ }
+ if !strings.Contains(body, `function cancelTaskDetail(id)`) {
+ t.Fatalf("task detail page missing cancel handler: %s", body)
+ }
+ if !strings.Contains(body, `/api/tasks/' + id + '/cancel`) {
+ t.Fatalf("task detail page missing cancel endpoint: %s", body)
+ }
+ if !strings.Contains(body, `id="task-live-charts"`) {
+ t.Fatalf("task detail page missing live charts container: %s", body)
+ }
+ if !strings.Contains(body, `/api/tasks/' + taskId + '/charts`) {
+ t.Fatalf("task detail page missing live charts index endpoint: %s", body)
+ }
+}
+
+func TestTaskChartSVGUsesTaskTimeWindow(t *testing.T) {
+ dir := t.TempDir()
+ metricsPath := filepath.Join(dir, "metrics.db")
+ prevMetricsPath := taskReportMetricsDBPath
+ taskReportMetricsDBPath = metricsPath
+ t.Cleanup(func() { taskReportMetricsDBPath = prevMetricsPath })
+
+ db, err := openMetricsDB(metricsPath)
+ if err != nil {
+ t.Fatalf("openMetricsDB: %v", err)
+ }
+ base := time.Now().UTC()
+ samples := []platform.LiveMetricSample{
+ {Timestamp: base.Add(-3 * time.Minute), PowerW: 100},
+ {Timestamp: base.Add(-2 * time.Minute), PowerW: 200},
+ {Timestamp: base.Add(-1 * time.Minute), PowerW: 300},
+ }
+ for _, sample := range samples {
+ if err := db.Write(sample); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ }
+ _ = db.Close()
+
+ started := base.Add(-2*time.Minute - 5*time.Second)
+ done := base.Add(-1*time.Minute + 5*time.Second)
+ globalQueue.mu.Lock()
+ origTasks := globalQueue.tasks
+ globalQueue.tasks = []*Task{{
+ ID: "task-chart-1",
+ Name: "Power Window",
+ Target: "cpu",
+ Status: TaskDone,
+ CreatedAt: started.Add(-10 * time.Second),
+ StartedAt: &started,
+ DoneAt: &done,
+ }}
+ globalQueue.mu.Unlock()
+ t.Cleanup(func() {
+ globalQueue.mu.Lock()
+ globalQueue.tasks = origTasks
+ globalQueue.mu.Unlock()
+ })
+
+ handler := NewHandler(HandlerOptions{Title: "Bee Hardware Audit"})
+ req := httptest.NewRequest(http.MethodGet, "/api/tasks/task-chart-1/chart/server-power.svg", nil)
+ req.SetPathValue("id", "task-chart-1")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "System Power") {
+ t.Fatalf("task chart missing expected title: %s", body)
+ }
+ if !strings.Contains(body, "min 200") {
+ t.Fatalf("task chart stats should start from in-window sample: %s", body)
+ }
+ if strings.Contains(body, "min 100") {
+ t.Fatalf("task chart should not include pre-task sample in stats: %s", body)
+ }
+}
+
func TestViewerRendersLatestSnapshot(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.json")
diff --git a/audit/internal/webui/task_page.go b/audit/internal/webui/task_page.go
index 3ebf0d3..7a372d2 100644
--- a/audit/internal/webui/task_page.go
+++ b/audit/internal/webui/task_page.go
@@ -1,11 +1,15 @@
package webui
import (
+ "encoding/json"
"fmt"
"html"
"net/http"
"os"
"strings"
+ "time"
+
+ "bee/audit/internal/platform"
)
func (h *handler) handleTaskPage(w http.ResponseWriter, r *http.Request) {
@@ -22,6 +26,51 @@ func (h *handler) handleTaskPage(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}
+func (h *handler) handleAPITaskChartsIndex(w http.ResponseWriter, r *http.Request) {
+ task, samples, _, _, ok := h.taskSamplesForRequest(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ type taskChartIndexEntry struct {
+ Title string `json:"title"`
+ File string `json:"file"`
+ }
+ entries := make([]taskChartIndexEntry, 0)
+ for _, spec := range taskChartSpecsForSamples(samples) {
+ title, _, ok := renderTaskChartSVG(spec.Path, samples, taskTimelineForTask(task))
+ if !ok {
+ continue
+ }
+ entries = append(entries, taskChartIndexEntry{Title: title, File: spec.File})
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(entries)
+}
+
+func (h *handler) handleAPITaskChartSVG(w http.ResponseWriter, r *http.Request) {
+ task, samples, _, _, ok := h.taskSamplesForRequest(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ file := strings.TrimPrefix(r.URL.Path, "/api/tasks/"+task.ID+"/chart/")
+ path, ok := taskChartPathFromFile(file)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ title, buf, hasData := renderTaskChartSVG(path, samples, taskTimelineForTask(task))
+ if !hasData || len(buf) == 0 || strings.TrimSpace(title) == "" {
+ http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ w.Header().Set("Content-Type", "image/svg+xml")
+ w.Header().Set("Cache-Control", "no-store")
+ _, _ = w.Write(buf)
+}
+
func renderTaskDetailPage(opts HandlerOptions, task Task) string {
title := task.Name
if strings.TrimSpace(title) == "" {
@@ -30,6 +79,9 @@ func renderTaskDetailPage(opts HandlerOptions, task Task) string {
var body strings.Builder
body.WriteString(``)
body.WriteString(`
Back to Tasks `)
+ if task.Status == TaskRunning || task.Status == TaskPending {
+ body.WriteString(`
Cancel `)
+ }
body.WriteString(`
Artifacts are saved in the task folder under ./tasks. `)
body.WriteString(`
`)
@@ -46,16 +98,52 @@ func renderTaskDetailPage(opts HandlerOptions, task Task) string {
}
if task.Status == TaskRunning || task.Status == TaskPending {
+ body.WriteString(`Live Charts
`)
+ body.WriteString(`
Loading charts...
`)
+ body.WriteString(`
`)
body.WriteString(`Live Logs
`)
body.WriteString(`
Connecting...
`)
body.WriteString(`
`)
body.WriteString(``)
}
@@ -83,3 +171,37 @@ func taskArtifactDownloadLink(task Task, absPath string) string {
}
return fmt.Sprintf(`/export/file?path=%s`, absPath)
}
+
+func (h *handler) taskSamplesForRequest(r *http.Request) (Task, []platform.LiveMetricSample, time.Time, time.Time, bool) {
+ id := r.PathValue("id")
+ taskPtr, ok := globalQueue.findByID(id)
+ if !ok {
+ return Task{}, nil, time.Time{}, time.Time{}, false
+ }
+ task := *taskPtr
+ start, end := taskTimeWindow(&task)
+ samples, err := loadTaskMetricSamples(start, end)
+ if err != nil {
+ return task, nil, start, end, true
+ }
+ return task, samples, start, end, true
+}
+
+func taskTimelineForTask(task Task) []chartTimelineSegment {
+ start, end := taskTimeWindow(&task)
+ return []chartTimelineSegment{{Start: start, End: end, Active: true}}
+}
+
+func taskChartPathFromFile(file string) (string, bool) {
+ file = strings.TrimSpace(file)
+ for _, spec := range taskDashboardChartSpecs {
+ if spec.File == file {
+ return spec.Path, true
+ }
+ }
+ if strings.HasPrefix(file, "gpu-") && strings.HasSuffix(file, "-overview.svg") {
+ id := strings.TrimSuffix(strings.TrimPrefix(file, "gpu-"), "-overview.svg")
+ return "gpu/" + id + "-overview", true
+ }
+ return "", false
+}
diff --git a/audit/internal/webui/task_report.go b/audit/internal/webui/task_report.go
index 23d8573..54fd710 100644
--- a/audit/internal/webui/task_report.go
+++ b/audit/internal/webui/task_report.go
@@ -53,6 +53,18 @@ var taskDashboardChartSpecs = []taskChartSpec{
{Path: "gpu-all-temp", File: "gpu-all-temp.svg"},
}
+func taskChartSpecsForSamples(samples []platform.LiveMetricSample) []taskChartSpec {
+ specs := make([]taskChartSpec, 0, len(taskDashboardChartSpecs)+len(taskGPUIndices(samples)))
+ specs = append(specs, taskDashboardChartSpecs...)
+ for _, idx := range taskGPUIndices(samples) {
+ specs = append(specs, taskChartSpec{
+ Path: fmt.Sprintf("gpu/%d-overview", idx),
+ File: fmt.Sprintf("gpu-%d-overview.svg", idx),
+ })
+ }
+ return specs
+}
+
func writeTaskReportArtifacts(t *Task) error {
if t == nil {
return nil
@@ -136,7 +148,7 @@ func writeTaskCharts(dir string, start, end time.Time, samples []platform.LiveMe
timeline := []chartTimelineSegment{{Start: start, End: end, Active: true}}
var charts []taskReportChart
inline := make(map[string]string)
- for _, spec := range taskDashboardChartSpecs {
+ for _, spec := range taskChartSpecsForSamples(samples) {
title, svg, ok := renderTaskChartSVG(spec.Path, samples, timeline)
if !ok || len(svg) == 0 {
continue
@@ -148,24 +160,17 @@ func writeTaskCharts(dir string, start, end time.Time, samples []platform.LiveMe
charts = append(charts, taskReportChart{Title: title, File: spec.File})
inline[spec.File] = string(svg)
}
-
- for _, idx := range taskGPUIndices(samples) {
- file := fmt.Sprintf("gpu-%d-overview.svg", idx)
- svg, ok, err := renderGPUOverviewChartSVG(idx, samples, timeline)
- if err != nil || !ok || len(svg) == 0 {
- continue
- }
- path := filepath.Join(dir, file)
- if err := os.WriteFile(path, svg, 0644); err != nil {
- continue
- }
- charts = append(charts, taskReportChart{Title: gpuDisplayLabel(idx) + " Overview", File: file})
- inline[file] = string(svg)
- }
return charts, inline
}
func renderTaskChartSVG(path string, samples []platform.LiveMetricSample, timeline []chartTimelineSegment) (string, []byte, bool) {
+ if idx, sub, ok := parseGPUChartPath(path); ok && sub == "overview" {
+ buf, hasData, err := renderGPUOverviewChartSVG(idx, samples, timeline)
+ if err != nil || !hasData {
+ return "", nil, false
+ }
+ return gpuDisplayLabel(idx) + " Overview", buf, true
+ }
datasets, names, labels, title, yMin, yMax, ok := chartDataFromSamples(path, samples)
if !ok {
return "", nil, false
@@ -227,13 +232,11 @@ func renderTaskReportFragment(report taskReport, charts map[string]string, logTe
b.WriteString(``)
if len(report.Charts) > 0 {
- b.WriteString(``)
for _, chart := range report.Charts {
b.WriteString(`
` + html.EscapeString(chart.Title) + `
`)
b.WriteString(charts[chart.File])
b.WriteString(`
`)
}
- b.WriteString(`
`)
} else {
b.WriteString(`No metric samples were captured during this task window.
`)
}