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 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 } 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 taskChartSpecsForSamples(samples) { 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) } 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 } 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(`