290 lines
8.6 KiB
Go
290 lines
8.6 KiB
Go
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(`<div class="card"><div class="card-head">Task Report</div><div class="card-body">`)
|
|
b.WriteString(`<div class="grid2">`)
|
|
b.WriteString(`<div><div style="font-size:12px;color:var(--muted);margin-bottom:6px">Task</div><div style="font-size:16px;font-weight:700">` + html.EscapeString(report.Name) + `</div>`)
|
|
b.WriteString(`<div style="font-size:13px;color:var(--muted)">` + html.EscapeString(report.Target) + `</div></div>`)
|
|
b.WriteString(`<div><div style="font-size:12px;color:var(--muted);margin-bottom:6px">Status</div><div>` + renderTaskStatusBadge(report.Status) + `</div>`)
|
|
if strings.TrimSpace(report.Error) != "" {
|
|
b.WriteString(`<div style="margin-top:8px;font-size:13px;color:var(--crit-fg)">` + html.EscapeString(report.Error) + `</div>`)
|
|
}
|
|
b.WriteString(`</div></div>`)
|
|
b.WriteString(`<div style="margin-top:14px;font-size:13px;color:var(--muted)">`)
|
|
b.WriteString(`Started: ` + formatTaskTime(report.StartedAt, report.CreatedAt) + ` | Finished: ` + formatTaskTime(report.DoneAt, time.Time{}) + ` | Duration: ` + formatTaskDuration(report.DurationSec))
|
|
b.WriteString(`</div></div></div>`)
|
|
|
|
if len(report.Charts) > 0 {
|
|
for _, chart := range report.Charts {
|
|
b.WriteString(`<div class="card"><div class="card-head">` + html.EscapeString(chart.Title) + `</div><div class="card-body" style="padding:12px">`)
|
|
b.WriteString(charts[chart.File])
|
|
b.WriteString(`</div></div>`)
|
|
}
|
|
} else {
|
|
b.WriteString(`<div class="alert alert-info">No metric samples were captured during this task window.</div>`)
|
|
}
|
|
|
|
b.WriteString(`<div class="card"><div class="card-head">Logs</div><div class="card-body">`)
|
|
b.WriteString(`<div class="terminal" style="max-height:none;white-space:pre-wrap">` + html.EscapeString(strings.TrimSpace(logText)) + `</div>`)
|
|
b.WriteString(`</div></div>`)
|
|
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 `<span class="badge ` + className + `">` + html.EscapeString(label) + `</span>`
|
|
}
|
|
|
|
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)
|
|
}
|