feat(tui): add state change history view with outage duration
Full-screen scrollable history view accessible via [h] from detail panel. Shows all state transitions with computed outage durations, event density sparkline for flapping detection, and summary stats. - Detail panel STATE CHANGES now shows outage duration per recovery - Event density sparkline highlights flapping periods - Summary footer: event count, outage count, avg outage duration - Vim-style navigation (j/k/g/G) + mouse scroll in history view
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func TestComputeOutageDuration(t *testing.T) {
|
||||
now := time.Date(2026, 6, 3, 14, 0, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
changes []models.StateChange
|
||||
idx int
|
||||
want time.Duration
|
||||
}{
|
||||
{
|
||||
"recovery with preceding DOWN",
|
||||
[]models.StateChange{
|
||||
{ToStatus: "UP", ChangedAt: now},
|
||||
{ToStatus: "DOWN", ChangedAt: now.Add(-10 * time.Minute)},
|
||||
},
|
||||
0,
|
||||
10 * time.Minute,
|
||||
},
|
||||
{
|
||||
"not a recovery transition",
|
||||
[]models.StateChange{
|
||||
{ToStatus: "DOWN", ChangedAt: now},
|
||||
{ToStatus: "UP", ChangedAt: now.Add(-1 * time.Hour)},
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"no preceding entry",
|
||||
[]models.StateChange{
|
||||
{ToStatus: "UP", ChangedAt: now},
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"preceding is also UP",
|
||||
[]models.StateChange{
|
||||
{ToStatus: "UP", ChangedAt: now},
|
||||
{ToStatus: "UP", ChangedAt: now.Add(-5 * time.Minute)},
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"empty slice",
|
||||
[]models.StateChange{},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"middle of list",
|
||||
[]models.StateChange{
|
||||
{ToStatus: "DOWN", ChangedAt: now},
|
||||
{ToStatus: "UP", ChangedAt: now.Add(-30 * time.Minute)},
|
||||
{ToStatus: "DOWN", ChangedAt: now.Add(-2 * time.Hour)},
|
||||
},
|
||||
1,
|
||||
90 * time.Minute,
|
||||
},
|
||||
{
|
||||
"recovery from LATE",
|
||||
[]models.StateChange{
|
||||
{ToStatus: "UP", ChangedAt: now},
|
||||
{ToStatus: "LATE", ChangedAt: now.Add(-5 * time.Minute)},
|
||||
},
|
||||
0,
|
||||
5 * time.Minute,
|
||||
},
|
||||
{
|
||||
"recovery from SSL EXP",
|
||||
[]models.StateChange{
|
||||
{ToStatus: "UP", ChangedAt: now},
|
||||
{ToStatus: "SSL EXP", ChangedAt: now.Add(-1 * time.Hour)},
|
||||
},
|
||||
0,
|
||||
1 * time.Hour,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.idx >= len(tt.changes) {
|
||||
if tt.want != 0 {
|
||||
t.Fatalf("invalid test: idx %d out of range", tt.idx)
|
||||
}
|
||||
return
|
||||
}
|
||||
got := computeOutageDuration(tt.changes, tt.idx)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHistoryStats(t *testing.T) {
|
||||
now := time.Date(2026, 6, 3, 14, 0, 0, 0, time.UTC)
|
||||
changes := []models.StateChange{
|
||||
{ToStatus: "UP", ChangedAt: now},
|
||||
{ToStatus: "DOWN", ChangedAt: now.Add(-10 * time.Minute)},
|
||||
{ToStatus: "UP", ChangedAt: now.Add(-1 * time.Hour)},
|
||||
{ToStatus: "DOWN", ChangedAt: now.Add(-3 * time.Hour)},
|
||||
}
|
||||
|
||||
stats := computeHistoryStats(changes)
|
||||
|
||||
if stats.totalEvents != 4 {
|
||||
t.Errorf("totalEvents: got %d, want 4", stats.totalEvents)
|
||||
}
|
||||
if stats.outageCount != 2 {
|
||||
t.Errorf("outageCount: got %d, want 2", stats.outageCount)
|
||||
}
|
||||
expectedDowntime := 10*time.Minute + 2*time.Hour
|
||||
if stats.totalDowntime != expectedDowntime {
|
||||
t.Errorf("totalDowntime: got %v, want %v", stats.totalDowntime, expectedDowntime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHistoryStats_Empty(t *testing.T) {
|
||||
stats := computeHistoryStats(nil)
|
||||
if stats.totalEvents != 0 || stats.outageCount != 0 || stats.totalDowntime != 0 {
|
||||
t.Errorf("expected zero stats for nil, got %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateChangeSparkline(t *testing.T) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
if got := stateChangeSparkline(nil, 20); got != "" {
|
||||
t.Errorf("expected empty for nil, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single event", func(t *testing.T) {
|
||||
changes := []models.StateChange{{ChangedAt: time.Now()}}
|
||||
if got := stateChangeSparkline(changes, 20); got != "" {
|
||||
t.Errorf("expected empty for single event, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two events produces output", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
changes := []models.StateChange{
|
||||
{ChangedAt: now},
|
||||
{ChangedAt: now.Add(-1 * time.Hour)},
|
||||
}
|
||||
got := stateChangeSparkline(changes, 20)
|
||||
if got == "" {
|
||||
t.Error("expected non-empty sparkline for two events")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("width too small", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
changes := []models.StateChange{
|
||||
{ChangedAt: now},
|
||||
{ChangedAt: now.Add(-1 * time.Hour)},
|
||||
}
|
||||
if got := stateChangeSparkline(changes, 3); got != "" {
|
||||
t.Errorf("expected empty for width 3, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user