diff --git a/docs/RELEASE-0.10-TASKS.md b/docs/RELEASE-0.10-TASKS.md index e66f5a2..c252ab3 100644 --- a/docs/RELEASE-0.10-TASKS.md +++ b/docs/RELEASE-0.10-TASKS.md @@ -93,8 +93,8 @@ Done first because both share a compact, single-line record formatter. - [x] T2.3 — `duration` log header - [x] T2.4 — runtime aggregate + `executeRun` update - [ ] T2.5 — seed stats from log files -- [ ] T2.6 — `DisplayStats` + Statistics row -- [ ] T2.7 — stats tests +- [x] T2.6 — `DisplayStats` + Statistics row +- [x] T2.7 — stats tests ### Phase 3 — Per-job run policy - [ ] T3.1 — `Job.OverlapPolicy` field diff --git a/src/app/format.go b/src/app/format.go index a784378..4a33ef6 100644 --- a/src/app/format.go +++ b/src/app/format.go @@ -82,6 +82,16 @@ func DisplayInvocation(job domain.Job) string { return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ") } +// DisplayStats returns a one-line execution-time summary for a job runtime. +// Returns "No runs recorded" when no runs have been counted yet. +func DisplayStats(rt *domain.JobRuntime) string { + if rt == nil || rt.RunCount == 0 { + return "No runs recorded" + } + return fmt.Sprintf("%d runs, %d failed, last %d ms, avg %d ms, max %d ms", + rt.RunCount, rt.FailCount, rt.LastDurationMS, rt.AvgDurationMS, rt.MaxDurationMS) +} + // DisplayIndex returns the position of jobIndex in the given slice of indexes, // or 0 if not found. func DisplayIndex(indexes []int, jobIndex int) int { diff --git a/src/app/format_test.go b/src/app/format_test.go index 3da9693..2b143ed 100644 --- a/src/app/format_test.go +++ b/src/app/format_test.go @@ -99,6 +99,35 @@ func TestDisplayIndex(t *testing.T) { } } +func TestDisplayStats(t *testing.T) { + // Zero RunCount → sentinel string. + if got := DisplayStats(nil); got != "No runs recorded" { + t.Errorf("nil runtime = %q, want %q", got, "No runs recorded") + } + if got := DisplayStats(&domain.JobRuntime{}); got != "No runs recorded" { + t.Errorf("zero runtime = %q, want %q", got, "No runs recorded") + } + + rt := &domain.JobRuntime{ + RunCount: 5, + FailCount: 2, + LastDurationMS: 450, + AvgDurationMS: 380, + MaxDurationMS: 520, + } + want := "5 runs, 2 failed, last 450 ms, avg 380 ms, max 520 ms" + if got := DisplayStats(rt); got != want { + t.Errorf("DisplayStats = %q, want %q", got, want) + } + + // Zero failures are included in the output (not hidden). + rtNoFail := &domain.JobRuntime{RunCount: 3, FailCount: 0, LastDurationMS: 100, AvgDurationMS: 90, MaxDurationMS: 110} + wantNoFail := "3 runs, 0 failed, last 100 ms, avg 90 ms, max 110 ms" + if got := DisplayStats(rtNoFail); got != wantNoFail { + t.Errorf("DisplayStats no-fail = %q, want %q", got, wantNoFail) + } +} + func TestEventLine(t *testing.T) { withLog := domain.RunRecord{ Time: "2026-06-19 12:00:00", Trigger: "Schedule", JobName: "Build", diff --git a/src/app/run_test.go b/src/app/run_test.go index b8feb5f..baa2d8b 100644 --- a/src/app/run_test.go +++ b/src/app/run_test.go @@ -68,6 +68,43 @@ func expectNoEntry(t *testing.T, entered <-chan int) { } } +// TestUpdateStats verifies that aggregate statistics are folded correctly after +// a sequence of fake runs with varying durations and states. +func TestUpdateStats(t *testing.T) { + rt := &domain.JobRuntime{} + + // First run: success, 200 ms. + updateStats(rt, domain.RunRecord{State: "OK", DurationMS: 200}) + if rt.RunCount != 1 || rt.FailCount != 0 { + t.Fatalf("after run 1: RunCount=%d FailCount=%d, want 1/0", rt.RunCount, rt.FailCount) + } + if rt.LastDurationMS != 200 || rt.MaxDurationMS != 200 || rt.AvgDurationMS != 200 { + t.Errorf("after run 1: last=%d max=%d avg=%d, want 200/200/200", + rt.LastDurationMS, rt.MaxDurationMS, rt.AvgDurationMS) + } + + // Second run: failure, 400 ms. + updateStats(rt, domain.RunRecord{State: "Failed", DurationMS: 400}) + if rt.RunCount != 2 || rt.FailCount != 1 { + t.Fatalf("after run 2: RunCount=%d FailCount=%d, want 2/1", rt.RunCount, rt.FailCount) + } + if rt.LastDurationMS != 400 || rt.MaxDurationMS != 400 { + t.Errorf("after run 2: last=%d max=%d, want 400/400", rt.LastDurationMS, rt.MaxDurationMS) + } + if rt.AvgDurationMS != 300 { + t.Errorf("after run 2: avg=%d, want 300", rt.AvgDurationMS) + } + + // Third run: success, 100 ms — avg should be (200+400+100)/3 = 233. + updateStats(rt, domain.RunRecord{State: "OK", DurationMS: 100}) + if rt.LastDurationMS != 100 || rt.MaxDurationMS != 400 { + t.Errorf("after run 3: last=%d max=%d, want 100/400", rt.LastDurationMS, rt.MaxDurationMS) + } + if rt.AvgDurationMS != 233 { + t.Errorf("after run 3: avg=%d, want 233", rt.AvgDurationMS) + } +} + // TestRunDueParallelStartsAllDueJobs verifies that in parallel mode every due job // starts at once: both runs are in flight (blocked in the runner) before either // is released. diff --git a/src/runner/seed_test.go b/src/runner/seed_test.go new file mode 100644 index 0000000..b964f9a --- /dev/null +++ b/src/runner/seed_test.go @@ -0,0 +1,149 @@ +package runner + +import ( + "os" + "path/filepath" + "testing" + + "gitea.mixdep.ru/mix/gosentry/src/domain" +) + +// writeTestLog writes a minimal log file in the format writeRunLog produces. +// Pass durationMS < 0 to omit the duration line (legacy log simulation). +func writeTestLog(t *testing.T, dir, filename, state string, durationMS int64) { + t.Helper() + var content string + if durationMS >= 0 { + content = "state: " + state + "\nduration: " + itoa(durationMS) + "\n\n" + } else { + content = "state: " + state + "\n\n" + } + if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func itoa(n int64) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + buf := make([]byte, 0, 20) + for n > 0 { + buf = append([]byte{byte('0' + n%10)}, buf...) + n /= 10 + } + if neg { + buf = append([]byte{'-'}, buf...) + } + return string(buf) +} + +func TestSeedStatsBasic(t *testing.T) { + dir := t.TempDir() + job := domain.Job{ID: 1, Name: "Build"} + name := sanitizeFileName(job.Name) + + writeTestLog(t, dir, "20260601-100000_"+name+".log", "OK", 200) + writeTestLog(t, dir, "20260601-110000_"+name+".log", "Failed", 400) + writeTestLog(t, dir, "20260601-120000_"+name+".log", "OK", 600) + + result := SeedStats(dir, []domain.Job{job}, 0) + s, ok := result[job.ID] + if !ok { + t.Fatal("expected stats for job 1") + } + if s.RunCount != 3 { + t.Errorf("RunCount = %d, want 3", s.RunCount) + } + if s.FailCount != 1 { + t.Errorf("FailCount = %d, want 1", s.FailCount) + } + if s.LastDurationMS != 600 { + t.Errorf("LastDurationMS = %d, want 600", s.LastDurationMS) + } + if s.MaxDurationMS != 600 { + t.Errorf("MaxDurationMS = %d, want 600", s.MaxDurationMS) + } + // avg = (200+400+600)/3 = 400 + if s.AvgDurationMS != 400 { + t.Errorf("AvgDurationMS = %d, want 400", s.AvgDurationMS) + } +} + +// TestSeedStatsDurationLessLegacyLog verifies that a log without a duration +// line still contributes to RunCount/FailCount but is excluded from duration +// aggregates, so a missing duration cannot masquerade as a 0 ms run. +func TestSeedStatsDurationLessLegacyLog(t *testing.T) { + dir := t.TempDir() + job := domain.Job{ID: 2, Name: "Deploy"} + name := sanitizeFileName(job.Name) + + // Legacy log (no duration line). + writeTestLog(t, dir, "20260601-080000_"+name+".log", "OK", -1) + // Modern log with duration. + writeTestLog(t, dir, "20260601-090000_"+name+".log", "OK", 300) + + result := SeedStats(dir, []domain.Job{job}, 0) + s := result[job.ID] + + if s.RunCount != 2 { + t.Errorf("RunCount = %d, want 2", s.RunCount) + } + if s.FailCount != 0 { + t.Errorf("FailCount = %d, want 0", s.FailCount) + } + // Only the modern log has a duration — avg/last/max must reflect that single entry. + if s.LastDurationMS != 300 { + t.Errorf("LastDurationMS = %d, want 300", s.LastDurationMS) + } + if s.AvgDurationMS != 300 { + t.Errorf("AvgDurationMS = %d, want 300", s.AvgDurationMS) + } +} + +// TestSeedStatsMaxFilesHonoured verifies that only the newest N logs are +// parsed when maxFiles is positive. +func TestSeedStatsMaxFilesHonoured(t *testing.T) { + dir := t.TempDir() + job := domain.Job{ID: 3, Name: "Cleanup"} + name := sanitizeFileName(job.Name) + + // Write 3 logs; only the 2 newest should be counted (maxFiles=2). + writeTestLog(t, dir, "20260601-060000_"+name+".log", "OK", 100) + writeTestLog(t, dir, "20260601-070000_"+name+".log", "OK", 200) + writeTestLog(t, dir, "20260601-080000_"+name+".log", "Failed", 300) + + result := SeedStats(dir, []domain.Job{job}, 2) + s := result[job.ID] + + if s.RunCount != 2 { + t.Errorf("RunCount = %d, want 2 (maxFiles=2)", s.RunCount) + } + if s.FailCount != 1 { + t.Errorf("FailCount = %d, want 1", s.FailCount) + } +} + +// TestSeedStatsMissingDir yields an empty map and does not panic. +func TestSeedStatsMissingDir(t *testing.T) { + result := SeedStats(filepath.Join(t.TempDir(), "no-such-dir"), []domain.Job{{ID: 1, Name: "J"}}, 0) + if len(result) != 0 { + t.Errorf("expected empty result for missing dir, got %v", result) + } +} + +// TestSeedStatsUnknownJobProducesNoEntry verifies that log files not matching +// any known job are silently ignored. +func TestSeedStatsUnknownJobProducesNoEntry(t *testing.T) { + dir := t.TempDir() + writeTestLog(t, dir, "20260601-100000_UnknownJob.log", "OK", 100) + + result := SeedStats(dir, []domain.Job{{ID: 1, Name: "KnownJob"}}, 0) + if _, ok := result[1]; ok { + t.Error("expected no entry for a job with no matching log files") + } +} diff --git a/src/ui/jobs_view.go b/src/ui/jobs_view.go index fdbcadc..39f373e 100644 --- a/src/ui/jobs_view.go +++ b/src/ui/jobs_view.go @@ -65,6 +65,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { lastRunLabel := newJobDetailLabel(selectedRuntime.LastRun) nextRunLabel := newJobDetailLabel(selectedRuntime.NextRun) stateLabel := newJobDetailLabel(selectedRuntime.LastState) + statsLabel := newJobDetailLabel(app.DisplayStats(selectedRuntime)) schedulerState := widget.NewLabel("Scheduler running") commandOutput := widget.NewTextGrid() commandOutput.SetText(selectedRuntime.Output) @@ -100,6 +101,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { lastRunLabel.SetText("") nextRunLabel.SetText("") stateLabel.SetText("") + statsLabel.SetText("") commandOutput.SetText("") selectedLogs = nil return @@ -116,6 +118,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { lastRunLabel.SetText(rt.LastRun) nextRunLabel.SetText(rt.NextRun) stateLabel.SetText(rt.LastState) + statsLabel.SetText(app.DisplayStats(rt)) commandOutput.SetText(rt.Output) selectedLogs = append(selectedLogs[:0], rt.Logs...) } @@ -343,6 +346,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { detailRow("Last run", lastRunLabel), detailRow("Next run", nextRunLabel), detailRow("State", stateLabel), + detailRow("Statistics", statsLabel), widget.NewSeparator(), widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), commandOutputScroll,