diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index 890debd..1b264d3 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -746,13 +746,7 @@ func (h *handler) feedRings(sample platform.LiveMetricSample) { h.ringMemLoad.push(sample.MemLoadPct) h.ringsMu.Lock() - for i, fan := range sample.Fans { - for len(h.ringFans) <= i { - h.ringFans = append(h.ringFans, newMetricsRing(120)) - h.fanNames = append(h.fanNames, fan.Name) - } - h.ringFans[i].push(float64(fan.RPM)) - } + h.pushFanRings(sample.Fans) for _, gpu := range sample.GPUs { idx := gpu.GPUIndex for len(h.gpuRings) <= idx { @@ -771,6 +765,51 @@ func (h *handler) feedRings(sample platform.LiveMetricSample) { h.ringsMu.Unlock() } +func (h *handler) pushFanRings(fans []platform.FanReading) { + if len(fans) == 0 && len(h.ringFans) == 0 { + return + } + fanValues := make(map[string]float64, len(fans)) + for _, fan := range fans { + if fan.Name == "" { + continue + } + fanValues[fan.Name] = fan.RPM + found := false + for i, name := range h.fanNames { + if name == fan.Name { + found = true + if i >= len(h.ringFans) { + h.ringFans = append(h.ringFans, newMetricsRing(120)) + } + break + } + } + if !found { + h.fanNames = append(h.fanNames, fan.Name) + h.ringFans = append(h.ringFans, newMetricsRing(120)) + } + } + for i, ring := range h.ringFans { + if ring == nil { + continue + } + name := "" + if i < len(h.fanNames) { + name = h.fanNames[i] + } + if rpm, ok := fanValues[name]; ok { + ring.push(rpm) + continue + } + if last, ok := ring.latest(); ok { + ring.push(last) + continue + } + ring.push(0) + } +} + func (h *handler) pushNamedMetricRing(dst *[]*namedMetricsRing, name string, value float64) { if name == "" { return diff --git a/audit/internal/webui/api_test.go b/audit/internal/webui/api_test.go index d508073..3a15f25 100644 --- a/audit/internal/webui/api_test.go +++ b/audit/internal/webui/api_test.go @@ -7,6 +7,7 @@ import ( "testing" "bee/audit/internal/app" + "bee/audit/internal/platform" ) func TestXrandrCommandAddsDefaultX11Env(t *testing.T) { @@ -100,3 +101,29 @@ func TestHandleAPIExportBundleQueuesTask(t *testing.T) { t.Fatalf("target=%q want support-bundle", got) } } + +func TestPushFanRingsTracksByNameAndCarriesForwardMissingSamples(t *testing.T) { + h := &handler{} + h.pushFanRings([]platform.FanReading{ + {Name: "FAN_A", RPM: 4200}, + {Name: "FAN_B", RPM: 5100}, + }) + h.pushFanRings([]platform.FanReading{ + {Name: "FAN_B", RPM: 5200}, + }) + + if len(h.fanNames) != 2 || h.fanNames[0] != "FAN_A" || h.fanNames[1] != "FAN_B" { + t.Fatalf("fanNames=%v", h.fanNames) + } + aVals, _ := h.ringFans[0].snapshot() + bVals, _ := h.ringFans[1].snapshot() + if len(aVals) != 2 || len(bVals) != 2 { + t.Fatalf("fan ring lengths: A=%d B=%d", len(aVals), len(bVals)) + } + if aVals[1] != 4200 { + t.Fatalf("FAN_A should carry forward last value, got %v", aVals) + } + if bVals[1] != 5200 { + t.Fatalf("FAN_B should use latest sampled value, got %v", bVals) + } +} diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index 01fd4a2..7026a48 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -1601,7 +1601,7 @@ function loadTasks() { return; } const rows = tasks.map(t => { - const dur = t.started_at ? formatDur(t.started_at, t.done_at) : ''; + const dur = t.elapsed_sec ? formatDurSec(t.elapsed_sec) : ''; const statusClass = {running:'badge-ok',pending:'badge-unknown',done:'badge-ok',failed:'badge-err',cancelled:'badge-unknown'}[t.status]||'badge-unknown'; const statusLabel = {running:'▶ running',pending:'pending',done:'✓ done',failed:'✗ failed',cancelled:'cancelled'}[t.status]||t.status; let actions = ''; @@ -1626,14 +1626,11 @@ function loadTasks() { function escHtml(s) { return (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function fmtTime(s) { if (!s) return ''; try { return new Date(s).toLocaleTimeString(); } catch(e){ return s; } } -function formatDur(start, end) { - try { - const s = new Date(start), e = end ? new Date(end) : new Date(); - const sec = Math.round((e-s)/1000); - if (sec < 60) return sec+'s'; - const m = Math.floor(sec/60), ss = sec%60; - return m+'m '+ss+'s'; - } catch(e){ return ''; } +function formatDurSec(sec) { + sec = Math.max(0, Math.round(sec||0)); + if (sec < 60) return sec+'s'; + const m = Math.floor(sec/60), ss = sec%60; + return m+'m '+ss+'s'; } function cancelTask(id) { diff --git a/audit/internal/webui/server.go b/audit/internal/webui/server.go index 5e7a107..cce7d78 100644 --- a/audit/internal/webui/server.go +++ b/audit/internal/webui/server.go @@ -84,6 +84,15 @@ func (r *metricsRing) snapshot() ([]float64, []string) { return v, labels } +func (r *metricsRing) latest() (float64, bool) { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.vals) == 0 { + return 0, false + } + return r.vals[len(r.vals)-1], true +} + func timestampsSameLocalDay(times []time.Time) bool { if len(times) == 0 { return true @@ -871,7 +880,7 @@ func namedFanDatasets(samples []platform.LiveMetricSample) ([][]float64, []strin } } } - datasets = append(datasets, ds) + datasets = append(datasets, normalizeFanSeries(ds)) } return datasets, names } @@ -946,6 +955,27 @@ func normalizePowerSeries(ds []float64) []float64 { return out } +func normalizeFanSeries(ds []float64) []float64 { + if len(ds) == 0 { + return nil + } + out := make([]float64, len(ds)) + var lastPositive float64 + for i, v := range ds { + if v > 0 { + lastPositive = v + out[i] = v + continue + } + if lastPositive > 0 { + out[i] = lastPositive + continue + } + out[i] = 0 + } + return out +} + // floatPtr returns a pointer to a float64 value. func floatPtr(v float64) *float64 { return &v } @@ -1183,7 +1213,7 @@ func snapshotFanRings(rings []*metricsRing, fanNames []string) ([][]float64, []s continue } vals, l := ring.snapshot() - datasets = append(datasets, vals) + datasets = append(datasets, normalizeFanSeries(vals)) name := "Fan" if i < len(fanNames) { name = fanNames[i] diff --git a/audit/internal/webui/server_test.go b/audit/internal/webui/server_test.go index 5e075e2..99cd95d 100644 --- a/audit/internal/webui/server_test.go +++ b/audit/internal/webui/server_test.go @@ -149,6 +149,19 @@ func TestChartCanvasHeight(t *testing.T) { } } +func TestNormalizeFanSeriesHoldsLastPositive(t *testing.T) { + got := normalizeFanSeries([]float64{4200, 0, 0, 4300, 0}) + want := []float64{4200, 4200, 4200, 4300, 4300} + if len(got) != len(want) { + t.Fatalf("len=%d want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got[%d]=%v want %v", i, got[i], want[i]) + } + } +} + func TestChartYAxisOption(t *testing.T) { min := floatPtr(0) max := floatPtr(100) diff --git a/audit/internal/webui/tasks.go b/audit/internal/webui/tasks.go index 10f8395..82d14eb 100644 --- a/audit/internal/webui/tasks.go +++ b/audit/internal/webui/tasks.go @@ -83,16 +83,17 @@ 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"` - 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"` // runtime fields (not serialised) job *jobState @@ -101,11 +102,11 @@ type Task struct { // taskParams holds optional parameters parsed from the run request. type taskParams struct { - Duration int `json:"duration,omitempty"` - DiagLevel int `json:"diag_level,omitempty"` - GPUIndices []int `json:"gpu_indices,omitempty"` - ExcludeGPUIndices []int `json:"exclude_gpu_indices,omitempty"` - Loader string `json:"loader,omitempty"` + Duration int `json:"duration,omitempty"` + DiagLevel int `json:"diag_level,omitempty"` + GPUIndices []int `json:"gpu_indices,omitempty"` + ExcludeGPUIndices []int `json:"exclude_gpu_indices,omitempty"` + Loader string `json:"loader,omitempty"` BurnProfile string `json:"burn_profile,omitempty"` DisplayName string `json:"display_name,omitempty"` Device string `json:"device,omitempty"` // for install @@ -311,6 +312,7 @@ func (q *taskQueue) snapshot() []Task { out := make([]Task, len(q.tasks)) for i, t := range q.tasks { out[i] = *t + out[i].ElapsedSec = taskElapsedSec(&out[i], time.Now()) } sort.SliceStable(out, func(i, j int) bool { si := statusOrder(out[i].Status) @@ -769,6 +771,7 @@ func (q *taskQueue) loadLocked() { q.assignTaskLogPathLocked(t) if t.Status == TaskPending || t.Status == TaskRunning { t.Status = TaskPending + t.StartedAt = nil t.DoneAt = nil t.ErrMsg = "" } @@ -808,3 +811,21 @@ func (q *taskQueue) persistLocked() { } _ = os.Rename(tmp, q.statePath) } + +func taskElapsedSec(t *Task, now time.Time) int { + if t == nil || t.StartedAt == nil || t.StartedAt.IsZero() { + return 0 + } + start := *t.StartedAt + if !t.CreatedAt.IsZero() && start.Before(t.CreatedAt) { + start = t.CreatedAt + } + end := now + if t.DoneAt != nil && !t.DoneAt.IsZero() { + end = *t.DoneAt + } + if end.Before(start) { + return 0 + } + return int(end.Sub(start).Round(time.Second) / time.Second) +} diff --git a/audit/internal/webui/tasks_test.go b/audit/internal/webui/tasks_test.go index 61502cf..584b100 100644 --- a/audit/internal/webui/tasks_test.go +++ b/audit/internal/webui/tasks_test.go @@ -55,6 +55,9 @@ func TestTaskQueuePersistsAndRecoversPendingTasks(t *testing.T) { if got.Status != TaskPending { t.Fatalf("status=%q want %q", got.Status, TaskPending) } + if got.StartedAt != nil { + t.Fatalf("started_at=%v want nil for recovered pending task", got.StartedAt) + } if got.params.Duration != 300 || got.params.BurnProfile != "smoke" { t.Fatalf("params=%+v", got.params) } @@ -236,6 +239,26 @@ func TestRunTaskBuildsSupportBundleWithoutApp(t *testing.T) { } } +func TestTaskElapsedSecClampsInvalidStartedAt(t *testing.T) { + now := time.Date(2026, 4, 1, 19, 10, 0, 0, time.UTC) + created := time.Date(2026, 4, 1, 19, 4, 5, 0, time.UTC) + started := time.Time{} + task := &Task{ + Status: TaskRunning, + CreatedAt: created, + StartedAt: &started, + } + if got := taskElapsedSec(task, now); got != 0 { + t.Fatalf("taskElapsedSec(zero start)=%d want 0", got) + } + + stale := created.Add(-24 * time.Hour) + task.StartedAt = &stale + if got := taskElapsedSec(task, now); got != int(now.Sub(created).Seconds()) { + t.Fatalf("taskElapsedSec(stale start)=%d want %d", got, int(now.Sub(created).Seconds())) + } +} + func TestRunTaskInstallUsesSharedCommandStreaming(t *testing.T) { q := &taskQueue{ opts: &HandlerOptions{},