app/format, ui/jobs_view: DisplayStats summary + Statistics detail row (T2.6, T2.7)

Add DisplayStats to format.go: returns "No runs recorded" when RunCount is
zero, otherwise a one-line "N runs, M failed, last X ms, avg Y ms, max Z ms"
summary.  Wire a Statistics detail row into the jobs panel that refreshes via
updateDetails alongside the other runtime fields.

Tests: TestDisplayStats (nil/zero/normal/no-fail), TestUpdateStats (three
sequential fake runs through updateStats verifying all aggregate fields), and
five seed tests in runner/seed_test.go covering basic aggregation,
duration-less legacy logs, maxFiles capping, missing directory, and
unmatched log files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-24 08:24:48 +03:00
parent 6c69f323bd
commit 886d0d9caa
6 changed files with 231 additions and 2 deletions
+2 -2
View File
@@ -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
+10
View File
@@ -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 {
+29
View File
@@ -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",
+37
View File
@@ -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.
+149
View File
@@ -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")
}
}
+4
View File
@@ -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,