diff --git a/audit/internal/platform/sat.go b/audit/internal/platform/sat.go index 0a87222..d3cf98b 100644 --- a/audit/internal/platform/sat.go +++ b/audit/internal/platform/sat.go @@ -262,6 +262,9 @@ func (s *System) ListNvidiaGPUs() ([]NvidiaGPU, error) { MemoryMB: memMB, }) } + sort.Slice(gpus, func(i, j int) bool { + return gpus[i].Index < gpus[j].Index + }) return gpus, nil } diff --git a/audit/internal/webui/charts_svg.go b/audit/internal/webui/charts_svg.go index 6cf357a..ecbeda5 100644 --- a/audit/internal/webui/charts_svg.go +++ b/audit/internal/webui/charts_svg.go @@ -54,9 +54,9 @@ var metricChartPalette = []string{ } var gpuLabelCache struct { - mu sync.Mutex - loadedAt time.Time - byIndex map[int]string + mu sync.Mutex + loadedAt time.Time + byIndex map[int]string } func renderMetricChartSVG(title string, labels []string, times []time.Time, datasets [][]float64, names []string, yMin, yMax *float64, canvasHeight int, timeline []chartTimelineSegment) ([]byte, error) { @@ -125,8 +125,7 @@ func renderGPUOverviewChartSVG(idx int, samples []platform.LiveMetricSample, tim temp := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.TempC }) power := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.PowerW }) coreClock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.ClockMHz }) - memClock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.MemClockMHz }) - if temp == nil && power == nil && coreClock == nil && memClock == nil { + if temp == nil && power == nil && coreClock == nil { return nil, false, nil } labels := sampleTimeLabels(samples) @@ -139,7 +138,6 @@ func renderGPUOverviewChartSVG(idx int, samples []platform.LiveMetricSample, tim {Name: "Temp C", Values: coalesceDataset(temp, len(labels)), Color: "#f05a5a", AxisTitle: "Temp C"}, {Name: "Power W", Values: coalesceDataset(power, len(labels)), Color: "#ffb357", AxisTitle: "Power W"}, {Name: "Core Clock MHz", Values: coalesceDataset(coreClock, len(labels)), Color: "#73bf69", AxisTitle: "Core MHz"}, - {Name: "Memory Clock MHz", Values: coalesceDataset(memClock, len(labels)), Color: "#5794f2", AxisTitle: "Memory MHz"}, }, timeline, ) @@ -150,8 +148,8 @@ func renderGPUOverviewChartSVG(idx int, samples []platform.LiveMetricSample, tim } func drawGPUOverviewChartSVG(title string, labels []string, times []time.Time, series []metricChartSeries, timeline []chartTimelineSegment) ([]byte, error) { - if len(series) != 4 { - return nil, fmt.Errorf("gpu overview requires 4 series, got %d", len(series)) + if len(series) != 3 { + return nil, fmt.Errorf("gpu overview requires 3 series, got %d", len(series)) } const ( width = 1400 @@ -165,7 +163,6 @@ func drawGPUOverviewChartSVG(title string, labels []string, times []time.Time, s leftOuterAxis = 72 leftInnerAxis = 132 rightInnerAxis = 1268 - rightOuterAxis = 1328 ) layout := chartLayout{ Width: width, @@ -175,7 +172,7 @@ func drawGPUOverviewChartSVG(title string, labels []string, times []time.Time, s PlotTop: plotTop, PlotBottom: plotBottom, } - axisX := []int{leftOuterAxis, leftInnerAxis, rightInnerAxis, rightOuterAxis} + axisX := []int{leftOuterAxis, leftInnerAxis, rightInnerAxis} pointCount := len(labels) if len(times) > pointCount { pointCount = len(times) diff --git a/audit/internal/webui/metricsdb.go b/audit/internal/webui/metricsdb.go index 61379c2..ac6cb0a 100644 --- a/audit/internal/webui/metricsdb.go +++ b/audit/internal/webui/metricsdb.go @@ -22,6 +22,13 @@ type MetricsDB struct { db *sql.DB } +func (m *MetricsDB) Close() error { + if m == nil || m.db == nil { + return nil + } + return m.db.Close() +} + // 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 { @@ -164,6 +171,23 @@ func (m *MetricsDB) LoadAll() ([]platform.LiveMetricSample, error) { return m.loadSamples(`SELECT ts,cpu_load_pct,mem_load_pct,power_w FROM sys_metrics ORDER BY ts`, nil) } +// LoadBetween returns samples in chronological order within the given time window. +func (m *MetricsDB) LoadBetween(start, end time.Time) ([]platform.LiveMetricSample, error) { + if m == nil { + return nil, nil + } + if start.IsZero() || end.IsZero() { + return nil, nil + } + if end.Before(start) { + start, end = end, start + } + return m.loadSamples( + `SELECT ts,cpu_load_pct,mem_load_pct,power_w FROM sys_metrics WHERE ts>=? AND ts<=? ORDER BY ts`, + start.Unix(), end.Unix(), + ) +} + // loadSamples reconstructs LiveMetricSample rows from the normalized tables. func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetricSample, error) { rows, err := m.db.Query(query, args...) @@ -364,9 +388,6 @@ func (m *MetricsDB) ExportCSV(w io.Writer) error { return cw.Error() } -// Close closes the database. -func (m *MetricsDB) Close() { _ = m.db.Close() } - func nullFloat(v float64) sql.NullFloat64 { return sql.NullFloat64{Float64: v, Valid: true} } diff --git a/audit/internal/webui/metricsdb_test.go b/audit/internal/webui/metricsdb_test.go index 9ac264d..4ec7d08 100644 --- a/audit/internal/webui/metricsdb_test.go +++ b/audit/internal/webui/metricsdb_test.go @@ -143,3 +143,32 @@ CREATE TABLE temp_metrics ( t.Fatalf("MemClockMHz=%v want 2600", got) } } + +func TestMetricsDBLoadBetweenFiltersWindow(t *testing.T) { + db, err := openMetricsDB(filepath.Join(t.TempDir(), "metrics.db")) + if err != nil { + t.Fatalf("openMetricsDB: %v", err) + } + defer db.Close() + + base := time.Unix(1_700_000_000, 0).UTC() + for i := 0; i < 5; i++ { + if err := db.Write(platform.LiveMetricSample{ + Timestamp: base.Add(time.Duration(i) * time.Minute), + CPULoadPct: float64(i), + }); err != nil { + t.Fatalf("Write(%d): %v", i, err) + } + } + + got, err := db.LoadBetween(base.Add(1*time.Minute), base.Add(3*time.Minute)) + if err != nil { + t.Fatalf("LoadBetween: %v", err) + } + if len(got) != 3 { + t.Fatalf("LoadBetween len=%d want 3", len(got)) + } + if !got[0].Timestamp.Equal(base.Add(1*time.Minute)) || !got[2].Timestamp.Equal(base.Add(3*time.Minute)) { + t.Fatalf("window=%v..%v", got[0].Timestamp, got[2].Timestamp) + } +} diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index 77ce0be..0ad7835 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -834,12 +834,6 @@ func renderMetrics() string { GPU core clock -
-
GPU — Memory Clock
-
- GPU memory clock -
-
GPU — Power
@@ -2704,30 +2698,16 @@ func renderTasks() string { -Tasks run one at a time. Logs persist after navigation. +Open a task to view its saved logs and charts.

Loading...

- `) + } + + return layoutHead(opts.Title+" — "+title) + + layoutNav("tasks", opts.BuildLabel) + + `

` + html.EscapeString(title) + `

` + + body.String() + + `
` +} + +func loadTaskReportFragment(task Task) string { + if strings.TrimSpace(task.ReportHTMLPath) == "" { + return "" + } + data, err := os.ReadFile(task.ReportHTMLPath) + if err != nil || len(data) == 0 { + return "" + } + return string(data) +} + +func taskArtifactDownloadLink(task Task, absPath string) string { + if strings.TrimSpace(absPath) == "" { + return "" + } + return fmt.Sprintf(`/export/file?path=%s`, absPath) +} diff --git a/audit/internal/webui/task_report.go b/audit/internal/webui/task_report.go new file mode 100644 index 0000000..23d8573 --- /dev/null +++ b/audit/internal/webui/task_report.go @@ -0,0 +1,286 @@ +package webui + +import ( + "encoding/json" + "fmt" + "html" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "bee/audit/internal/platform" +) + +var taskReportMetricsDBPath = metricsDBPath + +type taskReport struct { + ID string `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + DoneAt *time.Time `json:"done_at,omitempty"` + DurationSec int `json:"duration_sec,omitempty"` + Error string `json:"error,omitempty"` + LogFile string `json:"log_file,omitempty"` + Charts []taskReportChart `json:"charts,omitempty"` + GeneratedAt time.Time `json:"generated_at"` +} + +type taskReportChart struct { + Title string `json:"title"` + File string `json:"file"` +} + +type taskChartSpec struct { + Path string + File string +} + +var taskDashboardChartSpecs = []taskChartSpec{ + {Path: "server-load", File: "server-load.svg"}, + {Path: "server-temp-cpu", File: "server-temp-cpu.svg"}, + {Path: "server-temp-ambient", File: "server-temp-ambient.svg"}, + {Path: "server-power", File: "server-power.svg"}, + {Path: "server-fans", File: "server-fans.svg"}, + {Path: "gpu-all-load", File: "gpu-all-load.svg"}, + {Path: "gpu-all-memload", File: "gpu-all-memload.svg"}, + {Path: "gpu-all-clock", File: "gpu-all-clock.svg"}, + {Path: "gpu-all-power", File: "gpu-all-power.svg"}, + {Path: "gpu-all-temp", File: "gpu-all-temp.svg"}, +} + +func writeTaskReportArtifacts(t *Task) error { + if t == nil { + return nil + } + ensureTaskReportPaths(t) + if strings.TrimSpace(t.ArtifactsDir) == "" { + return nil + } + if err := os.MkdirAll(t.ArtifactsDir, 0755); err != nil { + return err + } + + start, end := taskTimeWindow(t) + samples, _ := loadTaskMetricSamples(start, end) + charts, inlineCharts := writeTaskCharts(t.ArtifactsDir, start, end, samples) + + logText := "" + if data, err := os.ReadFile(t.LogPath); err == nil { + logText = string(data) + } + + report := taskReport{ + ID: t.ID, + Name: t.Name, + Target: t.Target, + Status: t.Status, + CreatedAt: t.CreatedAt, + StartedAt: t.StartedAt, + DoneAt: t.DoneAt, + DurationSec: taskElapsedSec(t, reportDoneTime(t)), + Error: t.ErrMsg, + LogFile: filepath.Base(t.LogPath), + Charts: charts, + GeneratedAt: time.Now().UTC(), + } + if err := writeJSONFile(t.ReportJSONPath, report); err != nil { + return err + } + return os.WriteFile(t.ReportHTMLPath, []byte(renderTaskReportFragment(report, inlineCharts, logText)), 0644) +} + +func reportDoneTime(t *Task) time.Time { + if t != nil && t.DoneAt != nil && !t.DoneAt.IsZero() { + return *t.DoneAt + } + return time.Now() +} + +func taskTimeWindow(t *Task) (time.Time, time.Time) { + if t == nil { + now := time.Now().UTC() + return now, now + } + start := t.CreatedAt.UTC() + if t.StartedAt != nil && !t.StartedAt.IsZero() { + start = t.StartedAt.UTC() + } + end := time.Now().UTC() + if t.DoneAt != nil && !t.DoneAt.IsZero() { + end = t.DoneAt.UTC() + } + if end.Before(start) { + end = start + } + return start, end +} + +func loadTaskMetricSamples(start, end time.Time) ([]platform.LiveMetricSample, error) { + db, err := openMetricsDB(taskReportMetricsDBPath) + if err != nil { + return nil, err + } + defer db.Close() + return db.LoadBetween(start, end) +} + +func writeTaskCharts(dir string, start, end time.Time, samples []platform.LiveMetricSample) ([]taskReportChart, map[string]string) { + if len(samples) == 0 { + return nil, nil + } + timeline := []chartTimelineSegment{{Start: start, End: end, Active: true}} + var charts []taskReportChart + inline := make(map[string]string) + for _, spec := range taskDashboardChartSpecs { + title, svg, ok := renderTaskChartSVG(spec.Path, samples, timeline) + if !ok || len(svg) == 0 { + continue + } + path := filepath.Join(dir, spec.File) + if err := os.WriteFile(path, svg, 0644); err != nil { + continue + } + 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) { + datasets, names, labels, title, yMin, yMax, ok := chartDataFromSamples(path, samples) + if !ok { + return "", nil, false + } + buf, err := renderMetricChartSVG( + title, + labels, + sampleTimes(samples), + datasets, + names, + yMin, + yMax, + chartCanvasHeightForPath(path, len(names)), + timeline, + ) + if err != nil { + return "", nil, false + } + return title, buf, true +} + +func taskGPUIndices(samples []platform.LiveMetricSample) []int { + seen := map[int]bool{} + var out []int + for _, s := range samples { + for _, g := range s.GPUs { + if seen[g.GPUIndex] { + continue + } + seen[g.GPUIndex] = true + out = append(out, g.GPUIndex) + } + } + sort.Ints(out) + return out +} + +func writeJSONFile(path string, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +func renderTaskReportFragment(report taskReport, charts map[string]string, logText string) string { + var b strings.Builder + b.WriteString(`
Task Report
`) + b.WriteString(`
`) + b.WriteString(`
Task
` + html.EscapeString(report.Name) + `
`) + b.WriteString(`
` + html.EscapeString(report.Target) + `
`) + b.WriteString(`
Status
` + renderTaskStatusBadge(report.Status) + `
`) + if strings.TrimSpace(report.Error) != "" { + b.WriteString(`
` + html.EscapeString(report.Error) + `
`) + } + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(`Started: ` + formatTaskTime(report.StartedAt, report.CreatedAt) + ` | Finished: ` + formatTaskTime(report.DoneAt, time.Time{}) + ` | Duration: ` + formatTaskDuration(report.DurationSec)) + 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.
`) + } + + b.WriteString(`
Logs
`) + b.WriteString(`
` + html.EscapeString(strings.TrimSpace(logText)) + `
`) + b.WriteString(`
`) + return b.String() +} + +func renderTaskStatusBadge(status string) string { + className := map[string]string{ + TaskRunning: "badge-ok", + TaskPending: "badge-unknown", + TaskDone: "badge-ok", + TaskFailed: "badge-err", + TaskCancelled: "badge-unknown", + }[status] + if className == "" { + className = "badge-unknown" + } + label := strings.TrimSpace(status) + if label == "" { + label = "unknown" + } + return `` + html.EscapeString(label) + `` +} + +func formatTaskTime(ts *time.Time, fallback time.Time) string { + if ts != nil && !ts.IsZero() { + return ts.Local().Format("2006-01-02 15:04:05") + } + if !fallback.IsZero() { + return fallback.Local().Format("2006-01-02 15:04:05") + } + return "n/a" +} + +func formatTaskDuration(sec int) string { + if sec <= 0 { + return "n/a" + } + if sec < 60 { + return fmt.Sprintf("%ds", sec) + } + if sec < 3600 { + return fmt.Sprintf("%dm %02ds", sec/60, sec%60) + } + return fmt.Sprintf("%dh %02dm %02ds", sec/3600, (sec%3600)/60, sec%60) +} diff --git a/audit/internal/webui/tasks.go b/audit/internal/webui/tasks.go index 4fb2c77..9e1753b 100644 --- a/audit/internal/webui/tasks.go +++ b/audit/internal/webui/tasks.go @@ -92,17 +92,20 @@ func taskDisplayName(target, profile, loader string) string { // Task represents one unit of work in the queue. type Task struct { - ID string `json:"id"` - Name string `json:"name"` - Target string `json:"target"` - Priority int `json:"priority"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - StartedAt *time.Time `json:"started_at,omitempty"` - DoneAt *time.Time `json:"done_at,omitempty"` - ElapsedSec int `json:"elapsed_sec,omitempty"` - ErrMsg string `json:"error,omitempty"` - LogPath string `json:"log_path,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + Priority int `json:"priority"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + DoneAt *time.Time `json:"done_at,omitempty"` + ElapsedSec int `json:"elapsed_sec,omitempty"` + ErrMsg string `json:"error,omitempty"` + LogPath string `json:"log_path,omitempty"` + ArtifactsDir string `json:"artifacts_dir,omitempty"` + ReportJSONPath string `json:"report_json_path,omitempty"` + ReportHTMLPath string `json:"report_html_path,omitempty"` // runtime fields (not serialised) job *jobState @@ -126,17 +129,20 @@ type taskParams struct { } type persistedTask struct { - ID string `json:"id"` - Name string `json:"name"` - Target string `json:"target"` - Priority int `json:"priority"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - StartedAt *time.Time `json:"started_at,omitempty"` - DoneAt *time.Time `json:"done_at,omitempty"` - ErrMsg string `json:"error,omitempty"` - LogPath string `json:"log_path,omitempty"` - Params taskParams `json:"params,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + Priority int `json:"priority"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + DoneAt *time.Time `json:"done_at,omitempty"` + ErrMsg string `json:"error,omitempty"` + LogPath string `json:"log_path,omitempty"` + ArtifactsDir string `json:"artifacts_dir,omitempty"` + ReportJSONPath string `json:"report_json_path,omitempty"` + ReportHTMLPath string `json:"report_html_path,omitempty"` + Params taskParams `json:"params,omitempty"` } type burnPreset struct { @@ -496,8 +502,6 @@ func (q *taskQueue) executeTask(t *Task, j *jobState, ctx context.Context) { func (q *taskQueue) finalizeTaskRun(t *Task, j *jobState) { q.mu.Lock() - defer q.mu.Unlock() - now := time.Now() t.DoneAt = &now if t.Status == TaskRunning { @@ -509,7 +513,13 @@ func (q *taskQueue) finalizeTaskRun(t *Task, j *jobState) { t.ErrMsg = "" } } + q.finalizeTaskArtifactPathsLocked(t) q.persistLocked() + q.mu.Unlock() + + if err := writeTaskReportArtifacts(t); err != nil { + appendJobLog(t.LogPath, "WARN: task report generation failed: "+err.Error()) + } } // setCPUGovernor writes the given governor to all CPU scaling_governor sysfs files. @@ -992,10 +1002,10 @@ func (h *handler) handleAPITasksStream(w http.ResponseWriter, r *http.Request) { } func (q *taskQueue) assignTaskLogPathLocked(t *Task) { - if t.LogPath != "" || q.logsDir == "" || t.ID == "" { + if q.logsDir == "" || t.ID == "" { return } - t.LogPath = filepath.Join(q.logsDir, t.ID+".log") + q.ensureTaskArtifactPathsLocked(t) } func (q *taskQueue) loadLocked() { @@ -1012,17 +1022,20 @@ func (q *taskQueue) loadLocked() { } for _, pt := range persisted { t := &Task{ - ID: pt.ID, - Name: pt.Name, - Target: pt.Target, - Priority: pt.Priority, - Status: pt.Status, - CreatedAt: pt.CreatedAt, - StartedAt: pt.StartedAt, - DoneAt: pt.DoneAt, - ErrMsg: pt.ErrMsg, - LogPath: pt.LogPath, - params: pt.Params, + ID: pt.ID, + Name: pt.Name, + Target: pt.Target, + Priority: pt.Priority, + Status: pt.Status, + CreatedAt: pt.CreatedAt, + StartedAt: pt.StartedAt, + DoneAt: pt.DoneAt, + ErrMsg: pt.ErrMsg, + LogPath: pt.LogPath, + ArtifactsDir: pt.ArtifactsDir, + ReportJSONPath: pt.ReportJSONPath, + ReportHTMLPath: pt.ReportHTMLPath, + params: pt.Params, } q.assignTaskLogPathLocked(t) if t.Status == TaskRunning { @@ -1053,17 +1066,20 @@ func (q *taskQueue) persistLocked() { state := make([]persistedTask, 0, len(q.tasks)) for _, t := range q.tasks { state = append(state, persistedTask{ - ID: t.ID, - Name: t.Name, - Target: t.Target, - Priority: t.Priority, - Status: t.Status, - CreatedAt: t.CreatedAt, - StartedAt: t.StartedAt, - DoneAt: t.DoneAt, - ErrMsg: t.ErrMsg, - LogPath: t.LogPath, - Params: t.params, + ID: t.ID, + Name: t.Name, + Target: t.Target, + Priority: t.Priority, + Status: t.Status, + CreatedAt: t.CreatedAt, + StartedAt: t.StartedAt, + DoneAt: t.DoneAt, + ErrMsg: t.ErrMsg, + LogPath: t.LogPath, + ArtifactsDir: t.ArtifactsDir, + ReportJSONPath: t.ReportJSONPath, + ReportHTMLPath: t.ReportHTMLPath, + Params: t.params, }) } data, err := json.MarshalIndent(state, "", " ") @@ -1094,3 +1110,88 @@ func taskElapsedSec(t *Task, now time.Time) int { } return int(end.Sub(start).Round(time.Second) / time.Second) } + +func taskFolderStatus(status string) string { + status = strings.TrimSpace(strings.ToLower(status)) + switch status { + case TaskRunning, TaskDone, TaskFailed, TaskCancelled: + return status + default: + return TaskPending + } +} + +func sanitizeTaskFolderPart(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" { + return "task" + } + var b strings.Builder + lastDash := false + for _, r := range s { + isAlnum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if isAlnum { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "task" + } + return out +} + +func taskArtifactsDir(root string, t *Task, status string) string { + if strings.TrimSpace(root) == "" || t == nil { + return "" + } + return filepath.Join(root, fmt.Sprintf("%s_%s_%s", t.ID, sanitizeTaskFolderPart(t.Name), taskFolderStatus(status))) +} + +func ensureTaskReportPaths(t *Task) { + if t == nil || strings.TrimSpace(t.ArtifactsDir) == "" { + return + } + if t.LogPath == "" || filepath.Base(t.LogPath) == "task.log" { + t.LogPath = filepath.Join(t.ArtifactsDir, "task.log") + } + t.ReportJSONPath = filepath.Join(t.ArtifactsDir, "report.json") + t.ReportHTMLPath = filepath.Join(t.ArtifactsDir, "report.html") +} + +func (q *taskQueue) ensureTaskArtifactPathsLocked(t *Task) { + if t == nil || strings.TrimSpace(q.logsDir) == "" || strings.TrimSpace(t.ID) == "" { + return + } + if strings.TrimSpace(t.ArtifactsDir) == "" { + t.ArtifactsDir = taskArtifactsDir(q.logsDir, t, t.Status) + } + if t.ArtifactsDir != "" { + _ = os.MkdirAll(t.ArtifactsDir, 0755) + } + ensureTaskReportPaths(t) +} + +func (q *taskQueue) finalizeTaskArtifactPathsLocked(t *Task) { + if t == nil || strings.TrimSpace(q.logsDir) == "" || strings.TrimSpace(t.ID) == "" { + return + } + q.ensureTaskArtifactPathsLocked(t) + dstDir := taskArtifactsDir(q.logsDir, t, t.Status) + if dstDir == "" { + return + } + if t.ArtifactsDir != "" && t.ArtifactsDir != dstDir { + if _, err := os.Stat(dstDir); err != nil { + _ = os.Rename(t.ArtifactsDir, dstDir) + } + t.ArtifactsDir = dstDir + } + ensureTaskReportPaths(t) +} diff --git a/audit/internal/webui/tasks_test.go b/audit/internal/webui/tasks_test.go index e8e1538..4ebf4e0 100644 --- a/audit/internal/webui/tasks_test.go +++ b/audit/internal/webui/tasks_test.go @@ -2,6 +2,7 @@ package webui import ( "context" + "encoding/json" "net/http" "net/http/httptest" "os" @@ -12,6 +13,7 @@ import ( "time" "bee/audit/internal/app" + "bee/audit/internal/platform" ) func TestTaskQueuePersistsAndRecoversPendingTasks(t *testing.T) { @@ -248,6 +250,81 @@ func TestHandleAPITasksStreamPendingTaskStartsSSEImmediately(t *testing.T) { t.Fatalf("stream did not emit queued status promptly, body=%q", rec.Body.String()) } +func TestFinalizeTaskRunCreatesReportFolderAndArtifacts(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().Add(-45 * time.Second) + if err := db.Write(platform.LiveMetricSample{ + Timestamp: base, + CPULoadPct: 42, + MemLoadPct: 35, + PowerW: 510, + }); err != nil { + t.Fatalf("Write: %v", err) + } + _ = db.Close() + + q := &taskQueue{ + statePath: filepath.Join(dir, "tasks-state.json"), + logsDir: filepath.Join(dir, "tasks"), + trigger: make(chan struct{}, 1), + } + if err := os.MkdirAll(q.logsDir, 0755); err != nil { + t.Fatal(err) + } + + started := time.Now().UTC().Add(-90 * time.Second) + task := &Task{ + ID: "task-1", + Name: "CPU SAT", + Target: "cpu", + Status: TaskRunning, + CreatedAt: started.Add(-10 * time.Second), + StartedAt: &started, + } + q.assignTaskLogPathLocked(task) + appendJobLog(task.LogPath, "line-1") + + job := newTaskJobState(task.LogPath) + job.finish("") + q.finalizeTaskRun(task, job) + + if task.Status != TaskDone { + t.Fatalf("status=%q want %q", task.Status, TaskDone) + } + if !strings.Contains(filepath.Base(task.ArtifactsDir), "_done") { + t.Fatalf("artifacts dir=%q", task.ArtifactsDir) + } + if _, err := os.Stat(task.ReportJSONPath); err != nil { + t.Fatalf("report json: %v", err) + } + if _, err := os.Stat(task.ReportHTMLPath); err != nil { + t.Fatalf("report html: %v", err) + } + var report taskReport + data, err := os.ReadFile(task.ReportJSONPath) + if err != nil { + t.Fatalf("ReadFile(report.json): %v", err) + } + if err := json.Unmarshal(data, &report); err != nil { + t.Fatalf("Unmarshal(report.json): %v", err) + } + if report.ID != task.ID || report.Status != TaskDone { + t.Fatalf("report=%+v", report) + } + if len(report.Charts) == 0 { + t.Fatalf("expected charts in report, got none") + } +} + func TestResolveBurnPreset(t *testing.T) { tests := []struct { profile string