208 lines
7.8 KiB
Go
208 lines
7.8 KiB
Go
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) {
|
|
id := r.PathValue("id")
|
|
task, ok := globalQueue.findByID(id)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
snapshot := *task
|
|
body := renderTaskDetailPage(h.opts, snapshot)
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = 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) == "" {
|
|
title = task.ID
|
|
}
|
|
var body strings.Builder
|
|
body.WriteString(`<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap">`)
|
|
body.WriteString(`<a class="btn btn-secondary btn-sm" href="/tasks">Back to Tasks</a>`)
|
|
if task.Status == TaskRunning || task.Status == TaskPending {
|
|
body.WriteString(`<button class="btn btn-danger btn-sm" onclick="cancelTaskDetail('` + html.EscapeString(task.ID) + `')">Cancel</button>`)
|
|
}
|
|
body.WriteString(`<span style="font-size:12px;color:var(--muted)">Artifacts are saved in the task folder under <code>./tasks</code>.</span>`)
|
|
body.WriteString(`</div>`)
|
|
|
|
if report := loadTaskReportFragment(task); report != "" {
|
|
body.WriteString(report)
|
|
} else {
|
|
body.WriteString(`<div class="card"><div class="card-head">Task Summary</div><div class="card-body">`)
|
|
body.WriteString(`<div style="font-size:18px;font-weight:700">` + html.EscapeString(title) + `</div>`)
|
|
body.WriteString(`<div style="margin-top:8px">` + renderTaskStatusBadge(task.Status) + `</div>`)
|
|
if strings.TrimSpace(task.ErrMsg) != "" {
|
|
body.WriteString(`<div style="margin-top:8px;color:var(--crit-fg)">` + html.EscapeString(task.ErrMsg) + `</div>`)
|
|
}
|
|
body.WriteString(`</div></div>`)
|
|
}
|
|
|
|
if task.Status == TaskRunning || task.Status == TaskPending {
|
|
body.WriteString(`<div class="card"><div class="card-head">Live Charts</div><div class="card-body">`)
|
|
body.WriteString(`<div id="task-live-charts" style="display:flex;flex-direction:column;gap:16px;color:var(--muted);font-size:13px">Loading charts...</div>`)
|
|
body.WriteString(`</div></div>`)
|
|
body.WriteString(`<div class="card"><div class="card-head">Live Logs</div><div class="card-body">`)
|
|
body.WriteString(`<div id="task-live-log" class="terminal" style="max-height:none;white-space:pre-wrap">Connecting...</div>`)
|
|
body.WriteString(`</div></div>`)
|
|
body.WriteString(`<script>
|
|
function cancelTaskDetail(id) {
|
|
fetch('/api/tasks/' + id + '/cancel', {method:'POST'}).then(function(){ window.location.reload(); });
|
|
}
|
|
function loadTaskLiveCharts(taskId) {
|
|
fetch('/api/tasks/' + taskId + '/charts').then(function(r){ return r.json(); }).then(function(charts){
|
|
const host = document.getElementById('task-live-charts');
|
|
if (!host) return;
|
|
if (!Array.isArray(charts) || charts.length === 0) {
|
|
host.innerHTML = 'Waiting for metric samples...';
|
|
return;
|
|
}
|
|
host.innerHTML = charts.map(function(chart) {
|
|
return '<div class="card" style="margin:0">' +
|
|
'<div class="card-head">' + chart.title + '</div>' +
|
|
'<div class="card-body" style="padding:12px">' +
|
|
'<img data-task-chart="1" data-base-src="/api/tasks/' + taskId + '/chart/' + chart.file + '" src="/api/tasks/' + taskId + '/chart/' + chart.file + '?t=' + Date.now() + '" style="width:100%;display:block;border-radius:6px" alt="' + chart.title + '">' +
|
|
'</div></div>';
|
|
}).join('');
|
|
}).catch(function(){
|
|
const host = document.getElementById('task-live-charts');
|
|
if (host) host.innerHTML = 'Task charts are unavailable.';
|
|
});
|
|
}
|
|
function refreshTaskLiveCharts() {
|
|
document.querySelectorAll('img[data-task-chart="1"]').forEach(function(img){
|
|
const base = img.dataset.baseSrc;
|
|
if (!base) return;
|
|
img.src = base + '?t=' + Date.now();
|
|
});
|
|
}
|
|
var _taskDetailES = new EventSource('/api/tasks/` + html.EscapeString(task.ID) + `/stream');
|
|
var _taskDetailTerm = document.getElementById('task-live-log');
|
|
var _taskChartTimer = null;
|
|
_taskDetailES.onopen = function(){ _taskDetailTerm.textContent = ''; };
|
|
_taskDetailES.onmessage = function(e){ _taskDetailTerm.textContent += e.data + "\n"; _taskDetailTerm.scrollTop = _taskDetailTerm.scrollHeight; };
|
|
_taskDetailES.addEventListener('done', function(){ if (_taskChartTimer) clearInterval(_taskChartTimer); _taskDetailES.close(); setTimeout(function(){ window.location.reload(); }, 1000); });
|
|
_taskDetailES.onerror = function(){ if (_taskChartTimer) clearInterval(_taskChartTimer); _taskDetailES.close(); };
|
|
loadTaskLiveCharts('` + html.EscapeString(task.ID) + `');
|
|
_taskChartTimer = setInterval(function(){ refreshTaskLiveCharts(); loadTaskLiveCharts('` + html.EscapeString(task.ID) + `'); }, 2000);
|
|
</script>`)
|
|
}
|
|
|
|
return layoutHead(opts.Title+" — "+title) +
|
|
layoutNav("tasks", opts.BuildLabel) +
|
|
`<div class="main"><div class="topbar"><h1>` + html.EscapeString(title) + `</h1></div><div class="content">` +
|
|
body.String() +
|
|
`</div></div></body></html>`
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|