Fix fan chart gaps and task durations
This commit is contained in:
@@ -746,13 +746,7 @@ func (h *handler) feedRings(sample platform.LiveMetricSample) {
|
|||||||
h.ringMemLoad.push(sample.MemLoadPct)
|
h.ringMemLoad.push(sample.MemLoadPct)
|
||||||
|
|
||||||
h.ringsMu.Lock()
|
h.ringsMu.Lock()
|
||||||
for i, fan := range sample.Fans {
|
h.pushFanRings(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))
|
|
||||||
}
|
|
||||||
for _, gpu := range sample.GPUs {
|
for _, gpu := range sample.GPUs {
|
||||||
idx := gpu.GPUIndex
|
idx := gpu.GPUIndex
|
||||||
for len(h.gpuRings) <= idx {
|
for len(h.gpuRings) <= idx {
|
||||||
@@ -771,6 +765,51 @@ func (h *handler) feedRings(sample platform.LiveMetricSample) {
|
|||||||
h.ringsMu.Unlock()
|
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) {
|
func (h *handler) pushNamedMetricRing(dst *[]*namedMetricsRing, name string, value float64) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"bee/audit/internal/app"
|
"bee/audit/internal/app"
|
||||||
|
"bee/audit/internal/platform"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestXrandrCommandAddsDefaultX11Env(t *testing.T) {
|
func TestXrandrCommandAddsDefaultX11Env(t *testing.T) {
|
||||||
@@ -100,3 +101,29 @@ func TestHandleAPIExportBundleQueuesTask(t *testing.T) {
|
|||||||
t.Fatalf("target=%q want support-bundle", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1601,7 +1601,7 @@ function loadTasks() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rows = tasks.map(t => {
|
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 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;
|
const statusLabel = {running:'▶ running',pending:'pending',done:'✓ done',failed:'✗ failed',cancelled:'cancelled'}[t.status]||t.status;
|
||||||
let actions = '<button class="btn btn-sm btn-secondary" onclick="viewLog(\''+t.id+'\',\''+escHtml(t.name)+'\')">Logs</button>';
|
let actions = '<button class="btn btn-sm btn-secondary" onclick="viewLog(\''+t.id+'\',\''+escHtml(t.name)+'\')">Logs</button>';
|
||||||
@@ -1626,14 +1626,11 @@ function loadTasks() {
|
|||||||
|
|
||||||
function escHtml(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function escHtml(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
function fmtTime(s) { if (!s) return ''; try { return new Date(s).toLocaleTimeString(); } catch(e){ return s; } }
|
function fmtTime(s) { if (!s) return ''; try { return new Date(s).toLocaleTimeString(); } catch(e){ return s; } }
|
||||||
function formatDur(start, end) {
|
function formatDurSec(sec) {
|
||||||
try {
|
sec = Math.max(0, Math.round(sec||0));
|
||||||
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';
|
if (sec < 60) return sec+'s';
|
||||||
const m = Math.floor(sec/60), ss = sec%60;
|
const m = Math.floor(sec/60), ss = sec%60;
|
||||||
return m+'m '+ss+'s';
|
return m+'m '+ss+'s';
|
||||||
} catch(e){ return ''; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelTask(id) {
|
function cancelTask(id) {
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ func (r *metricsRing) snapshot() ([]float64, []string) {
|
|||||||
return v, labels
|
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 {
|
func timestampsSameLocalDay(times []time.Time) bool {
|
||||||
if len(times) == 0 {
|
if len(times) == 0 {
|
||||||
return true
|
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
|
return datasets, names
|
||||||
}
|
}
|
||||||
@@ -946,6 +955,27 @@ func normalizePowerSeries(ds []float64) []float64 {
|
|||||||
return out
|
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.
|
// floatPtr returns a pointer to a float64 value.
|
||||||
func floatPtr(v float64) *float64 { return &v }
|
func floatPtr(v float64) *float64 { return &v }
|
||||||
|
|
||||||
@@ -1183,7 +1213,7 @@ func snapshotFanRings(rings []*metricsRing, fanNames []string) ([][]float64, []s
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
vals, l := ring.snapshot()
|
vals, l := ring.snapshot()
|
||||||
datasets = append(datasets, vals)
|
datasets = append(datasets, normalizeFanSeries(vals))
|
||||||
name := "Fan"
|
name := "Fan"
|
||||||
if i < len(fanNames) {
|
if i < len(fanNames) {
|
||||||
name = fanNames[i]
|
name = fanNames[i]
|
||||||
|
|||||||
@@ -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) {
|
func TestChartYAxisOption(t *testing.T) {
|
||||||
min := floatPtr(0)
|
min := floatPtr(0)
|
||||||
max := floatPtr(100)
|
max := floatPtr(100)
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ type Task struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
DoneAt *time.Time `json:"done_at,omitempty"`
|
DoneAt *time.Time `json:"done_at,omitempty"`
|
||||||
|
ElapsedSec int `json:"elapsed_sec,omitempty"`
|
||||||
ErrMsg string `json:"error,omitempty"`
|
ErrMsg string `json:"error,omitempty"`
|
||||||
LogPath string `json:"log_path,omitempty"`
|
LogPath string `json:"log_path,omitempty"`
|
||||||
|
|
||||||
@@ -311,6 +312,7 @@ func (q *taskQueue) snapshot() []Task {
|
|||||||
out := make([]Task, len(q.tasks))
|
out := make([]Task, len(q.tasks))
|
||||||
for i, t := range q.tasks {
|
for i, t := range q.tasks {
|
||||||
out[i] = *t
|
out[i] = *t
|
||||||
|
out[i].ElapsedSec = taskElapsedSec(&out[i], time.Now())
|
||||||
}
|
}
|
||||||
sort.SliceStable(out, func(i, j int) bool {
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
si := statusOrder(out[i].Status)
|
si := statusOrder(out[i].Status)
|
||||||
@@ -769,6 +771,7 @@ func (q *taskQueue) loadLocked() {
|
|||||||
q.assignTaskLogPathLocked(t)
|
q.assignTaskLogPathLocked(t)
|
||||||
if t.Status == TaskPending || t.Status == TaskRunning {
|
if t.Status == TaskPending || t.Status == TaskRunning {
|
||||||
t.Status = TaskPending
|
t.Status = TaskPending
|
||||||
|
t.StartedAt = nil
|
||||||
t.DoneAt = nil
|
t.DoneAt = nil
|
||||||
t.ErrMsg = ""
|
t.ErrMsg = ""
|
||||||
}
|
}
|
||||||
@@ -808,3 +811,21 @@ func (q *taskQueue) persistLocked() {
|
|||||||
}
|
}
|
||||||
_ = os.Rename(tmp, q.statePath)
|
_ = 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ func TestTaskQueuePersistsAndRecoversPendingTasks(t *testing.T) {
|
|||||||
if got.Status != TaskPending {
|
if got.Status != TaskPending {
|
||||||
t.Fatalf("status=%q want %q", 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" {
|
if got.params.Duration != 300 || got.params.BurnProfile != "smoke" {
|
||||||
t.Fatalf("params=%+v", got.params)
|
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) {
|
func TestRunTaskInstallUsesSharedCommandStreaming(t *testing.T) {
|
||||||
q := &taskQueue{
|
q := &taskQueue{
|
||||||
opts: &HandlerOptions{},
|
opts: &HandlerOptions{},
|
||||||
|
|||||||
Reference in New Issue
Block a user