From 5b9015451e8aacb2a62e6e5e3beb6086ad9798bc Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sun, 5 Apr 2026 20:14:23 +0300 Subject: [PATCH] Add live task charts and fix USB export actions --- audit/internal/webui/pages.go | 16 +++- audit/internal/webui/server.go | 2 + audit/internal/webui/server_test.go | 105 +++++++++++++++++++++++ audit/internal/webui/task_page.go | 126 +++++++++++++++++++++++++++- audit/internal/webui/task_report.go | 37 ++++---- 5 files changed, 263 insertions(+), 23 deletions(-) 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 = '' + - 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() { '' + '' + ''; }).join('') + '
DeviceFSSizeLabelModelActions
'+label+''+model+'' + - ' ' + - '' + + ' ' + + '' + '
' + '
'; @@ -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(``) + } 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.
`) }