Add live task charts and fix USB export actions
This commit is contained in:
@@ -2272,6 +2272,7 @@ function usbRefresh() {
|
|||||||
document.getElementById('usb-targets').innerHTML = '';
|
document.getElementById('usb-targets').innerHTML = '';
|
||||||
document.getElementById('usb-msg').textContent = '';
|
document.getElementById('usb-msg').textContent = '';
|
||||||
fetch('/api/export/usb').then(r=>r.json()).then(targets => {
|
fetch('/api/export/usb').then(r=>r.json()).then(targets => {
|
||||||
|
window._usbTargets = Array.isArray(targets) ? targets : [];
|
||||||
const st = document.getElementById('usb-status');
|
const st = document.getElementById('usb-status');
|
||||||
const ct = document.getElementById('usb-targets');
|
const ct = document.getElementById('usb-targets');
|
||||||
if (!targets || targets.length === 0) {
|
if (!targets || targets.length === 0) {
|
||||||
@@ -2280,7 +2281,7 @@ function usbRefresh() {
|
|||||||
}
|
}
|
||||||
st.textContent = targets.length + ' device(s) found:';
|
st.textContent = targets.length + ' device(s) found:';
|
||||||
ct.innerHTML = '<table><tr><th>Device</th><th>FS</th><th>Size</th><th>Label</th><th>Model</th><th>Actions</th></tr>' +
|
ct.innerHTML = '<table><tr><th>Device</th><th>FS</th><th>Size</th><th>Label</th><th>Model</th><th>Actions</th></tr>' +
|
||||||
targets.map(t => {
|
targets.map((t, idx) => {
|
||||||
const dev = t.device || '';
|
const dev = t.device || '';
|
||||||
const label = t.label || '';
|
const label = t.label || '';
|
||||||
const model = t.model || '';
|
const model = t.model || '';
|
||||||
@@ -2291,8 +2292,8 @@ function usbRefresh() {
|
|||||||
'<td>'+label+'</td>' +
|
'<td>'+label+'</td>' +
|
||||||
'<td style="font-size:12px;color:var(--muted)">'+model+'</td>' +
|
'<td style="font-size:12px;color:var(--muted)">'+model+'</td>' +
|
||||||
'<td style="white-space:nowrap">' +
|
'<td style="white-space:nowrap">' +
|
||||||
'<button class="btn btn-sm btn-primary" onclick="usbExport(\'audit\','+JSON.stringify(t)+',this)">Audit JSON</button> ' +
|
'<button class="btn btn-sm btn-primary" onclick="usbExport(\'audit\','+idx+',this)">Audit JSON</button> ' +
|
||||||
'<button class="btn btn-sm btn-secondary" onclick="usbExport(\'bundle\','+JSON.stringify(t)+',this)">Support Bundle</button>' +
|
'<button class="btn btn-sm btn-secondary" onclick="usbExport(\'bundle\','+idx+',this)">Support Bundle</button>' +
|
||||||
'<div class="usb-row-msg" style="margin-top:6px;font-size:12px;color:var(--muted)"></div>' +
|
'<div class="usb-row-msg" style="margin-top:6px;font-size:12px;color:var(--muted)"></div>' +
|
||||||
'</td></tr>';
|
'</td></tr>';
|
||||||
}).join('') + '</table>';
|
}).join('') + '</table>';
|
||||||
@@ -2300,7 +2301,14 @@ function usbRefresh() {
|
|||||||
document.getElementById('usb-status').textContent = 'Error: ' + e;
|
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 msg = document.getElementById('usb-msg');
|
||||||
const row = btn ? btn.closest('td') : null;
|
const row = btn ? btn.closest('td') : null;
|
||||||
const rowMsg = row ? row.querySelector('.usb-row-msg') : null;
|
const rowMsg = row ? row.querySelector('.usb-row-msg') : null;
|
||||||
|
|||||||
@@ -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}/cancel", h.handleAPITasksCancel)
|
||||||
mux.HandleFunc("POST /api/tasks/{id}/priority", h.handleAPITasksPriority)
|
mux.HandleFunc("POST /api/tasks/{id}/priority", h.handleAPITasksPriority)
|
||||||
mux.HandleFunc("GET /api/tasks/{id}/stream", h.handleAPITasksStream)
|
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)
|
mux.HandleFunc("GET /tasks/{id}", h.handleTaskPage)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
|
|||||||
@@ -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</button>`) {
|
||||||
|
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) {
|
func TestViewerRendersLatestSnapshot(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "audit.json")
|
path := filepath.Join(dir, "audit.json")
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package webui
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bee/audit/internal/platform"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *handler) handleTaskPage(w http.ResponseWriter, r *http.Request) {
|
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))
|
_, _ = 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 {
|
func renderTaskDetailPage(opts HandlerOptions, task Task) string {
|
||||||
title := task.Name
|
title := task.Name
|
||||||
if strings.TrimSpace(title) == "" {
|
if strings.TrimSpace(title) == "" {
|
||||||
@@ -30,6 +79,9 @@ func renderTaskDetailPage(opts HandlerOptions, task Task) string {
|
|||||||
var body strings.Builder
|
var body strings.Builder
|
||||||
body.WriteString(`<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap">`)
|
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>`)
|
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(`<span style="font-size:12px;color:var(--muted)">Artifacts are saved in the task folder under <code>./tasks</code>.</span>`)
|
||||||
body.WriteString(`</div>`)
|
body.WriteString(`</div>`)
|
||||||
|
|
||||||
@@ -46,16 +98,52 @@ func renderTaskDetailPage(opts HandlerOptions, task Task) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if task.Status == TaskRunning || task.Status == TaskPending {
|
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 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 id="task-live-log" class="terminal" style="max-height:none;white-space:pre-wrap">Connecting...</div>`)
|
||||||
body.WriteString(`</div></div>`)
|
body.WriteString(`</div></div>`)
|
||||||
body.WriteString(`<script>
|
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 _taskDetailES = new EventSource('/api/tasks/` + html.EscapeString(task.ID) + `/stream');
|
||||||
var _taskDetailTerm = document.getElementById('task-live-log');
|
var _taskDetailTerm = document.getElementById('task-live-log');
|
||||||
|
var _taskChartTimer = null;
|
||||||
_taskDetailES.onopen = function(){ _taskDetailTerm.textContent = ''; };
|
_taskDetailES.onopen = function(){ _taskDetailTerm.textContent = ''; };
|
||||||
_taskDetailES.onmessage = function(e){ _taskDetailTerm.textContent += e.data + "\n"; _taskDetailTerm.scrollTop = _taskDetailTerm.scrollHeight; };
|
_taskDetailES.onmessage = function(e){ _taskDetailTerm.textContent += e.data + "\n"; _taskDetailTerm.scrollTop = _taskDetailTerm.scrollHeight; };
|
||||||
_taskDetailES.addEventListener('done', function(){ _taskDetailES.close(); setTimeout(function(){ window.location.reload(); }, 1000); });
|
_taskDetailES.addEventListener('done', function(){ if (_taskChartTimer) clearInterval(_taskChartTimer); _taskDetailES.close(); setTimeout(function(){ window.location.reload(); }, 1000); });
|
||||||
_taskDetailES.onerror = function(){ _taskDetailES.close(); };
|
_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>`)
|
</script>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,3 +171,37 @@ func taskArtifactDownloadLink(task Task, absPath string) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf(`/export/file?path=%s`, absPath)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ var taskDashboardChartSpecs = []taskChartSpec{
|
|||||||
{Path: "gpu-all-temp", File: "gpu-all-temp.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 {
|
func writeTaskReportArtifacts(t *Task) error {
|
||||||
if t == nil {
|
if t == nil {
|
||||||
return 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}}
|
timeline := []chartTimelineSegment{{Start: start, End: end, Active: true}}
|
||||||
var charts []taskReportChart
|
var charts []taskReportChart
|
||||||
inline := make(map[string]string)
|
inline := make(map[string]string)
|
||||||
for _, spec := range taskDashboardChartSpecs {
|
for _, spec := range taskChartSpecsForSamples(samples) {
|
||||||
title, svg, ok := renderTaskChartSVG(spec.Path, samples, timeline)
|
title, svg, ok := renderTaskChartSVG(spec.Path, samples, timeline)
|
||||||
if !ok || len(svg) == 0 {
|
if !ok || len(svg) == 0 {
|
||||||
continue
|
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})
|
charts = append(charts, taskReportChart{Title: title, File: spec.File})
|
||||||
inline[spec.File] = string(svg)
|
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
|
return charts, inline
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTaskChartSVG(path string, samples []platform.LiveMetricSample, timeline []chartTimelineSegment) (string, []byte, bool) {
|
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)
|
datasets, names, labels, title, yMin, yMax, ok := chartDataFromSamples(path, samples)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", nil, false
|
return "", nil, false
|
||||||
@@ -227,13 +232,11 @@ func renderTaskReportFragment(report taskReport, charts map[string]string, logTe
|
|||||||
b.WriteString(`</div></div></div>`)
|
b.WriteString(`</div></div></div>`)
|
||||||
|
|
||||||
if len(report.Charts) > 0 {
|
if len(report.Charts) > 0 {
|
||||||
b.WriteString(`<div class="grid2">`)
|
|
||||||
for _, chart := range report.Charts {
|
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(`<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(charts[chart.File])
|
||||||
b.WriteString(`</div></div>`)
|
b.WriteString(`</div></div>`)
|
||||||
}
|
}
|
||||||
b.WriteString(`</div>`)
|
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(`<div class="alert alert-info">No metric samples were captured during this task window.</div>`)
|
b.WriteString(`<div class="alert alert-info">No metric samples were captured during this task window.</div>`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user