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:
+7
-2
@@ -70,6 +70,7 @@ const (
|
|||||||
stateFormUser
|
stateFormUser
|
||||||
stateConfirmDelete
|
stateConfirmDelete
|
||||||
stateFormMaint
|
stateFormMaint
|
||||||
|
stateHistory
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
@@ -91,8 +92,12 @@ type Model struct {
|
|||||||
|
|
||||||
logViewport viewport.Model
|
logViewport viewport.Model
|
||||||
logFilterImportant bool
|
logFilterImportant bool
|
||||||
isAdmin bool
|
|
||||||
zones *zone.Manager
|
historyViewport viewport.Model
|
||||||
|
historyChanges []models.StateChange
|
||||||
|
historySiteName string
|
||||||
|
isAdmin bool
|
||||||
|
zones *zone.Manager
|
||||||
|
|
||||||
deleteID int
|
deleteID int
|
||||||
deleteName string
|
deleteName string
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
)
|
)
|
||||||
@@ -122,6 +123,8 @@ func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.logViewport.Width = msg.Width - chromePadH
|
m.logViewport.Width = msg.Width - chromePadH
|
||||||
m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter)
|
m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter)
|
||||||
|
m.historyViewport.Width = msg.Width - chromePadH
|
||||||
|
m.historyViewport.Height = msg.Height - 10
|
||||||
return m, tea.ClearScreen
|
return m, tea.ClearScreen
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +137,14 @@ func (m *Model) handleTick(t time.Time) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if m.state == stateHistory {
|
||||||
|
if msg.Button == tea.MouseButtonWheelUp {
|
||||||
|
m.historyViewport.ScrollUp(3)
|
||||||
|
} else if msg.Button == tea.MouseButtonWheelDown {
|
||||||
|
m.historyViewport.ScrollDown(3)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers {
|
if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -187,6 +198,8 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
switch m.state {
|
switch m.state {
|
||||||
case stateDetail:
|
case stateDetail:
|
||||||
return m.handleDetailKey(msg)
|
return m.handleDetailKey(msg)
|
||||||
|
case stateHistory:
|
||||||
|
return m.handleHistoryKey(msg)
|
||||||
case stateAlertDetail:
|
case stateAlertDetail:
|
||||||
return m.handleAlertDetailKey(msg)
|
return m.handleAlertDetailKey(msg)
|
||||||
case stateDashboard, stateLogs, stateUsers:
|
case stateDashboard, stateLogs, stateUsers:
|
||||||
@@ -229,12 +242,47 @@ func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "i", "esc":
|
case "i", "esc":
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
|
case "h":
|
||||||
|
if m.cursor < len(m.sites) {
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
m.historySiteName = site.Name
|
||||||
|
m.historyChanges = m.engine.GetStateChanges(site.ID, 100)
|
||||||
|
m.historyViewport = viewport.New(
|
||||||
|
m.termWidth-chromePadH,
|
||||||
|
m.termHeight-10,
|
||||||
|
)
|
||||||
|
m.historyViewport.SetContent(m.buildHistoryContent())
|
||||||
|
m.historyViewport.GotoTop()
|
||||||
|
m.state = stateHistory
|
||||||
|
}
|
||||||
case "q":
|
case "q":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "esc":
|
||||||
|
m.state = stateDetail
|
||||||
|
case "up", "k":
|
||||||
|
m.historyViewport.LineUp(1)
|
||||||
|
case "down", "j":
|
||||||
|
m.historyViewport.LineDown(1)
|
||||||
|
case "pgup":
|
||||||
|
m.historyViewport.HalfViewUp()
|
||||||
|
case "pgdown":
|
||||||
|
m.historyViewport.HalfViewDown()
|
||||||
|
case "home", "g":
|
||||||
|
m.historyViewport.GotoTop()
|
||||||
|
case "end", "G":
|
||||||
|
m.historyViewport.GotoBottom()
|
||||||
|
case "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "i", "esc":
|
case "i", "esc":
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ func (m Model) View() string {
|
|||||||
return ""
|
return ""
|
||||||
case stateDetail:
|
case stateDetail:
|
||||||
return m.viewDetailPanel()
|
return m.viewDetailPanel()
|
||||||
|
case stateHistory:
|
||||||
|
return m.viewHistoryPanel()
|
||||||
case stateAlertDetail:
|
case stateAlertDetail:
|
||||||
return m.viewAlertDetailPanel()
|
return m.viewAlertDetailPanel()
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func (m Model) viewDetailPanel() string {
|
|||||||
stateChanges := m.engine.GetStateChanges(site.ID, 5)
|
stateChanges := m.engine.GetStateChanges(site.ID, 5)
|
||||||
if len(stateChanges) > 0 {
|
if len(stateChanges) > 0 {
|
||||||
b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n")
|
b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n")
|
||||||
for _, sc := range stateChanges {
|
for i, sc := range stateChanges {
|
||||||
ago := fmtDuration(time.Since(sc.ChangedAt))
|
ago := fmtDuration(time.Since(sc.ChangedAt))
|
||||||
arrow := subtleStyle.Render(sc.FromStatus) + " → "
|
arrow := subtleStyle.Render(sc.FromStatus) + " → "
|
||||||
if sc.ToStatus == "UP" {
|
if sc.ToStatus == "UP" {
|
||||||
@@ -185,11 +185,15 @@ func (m Model) viewDetailPanel() string {
|
|||||||
arrow += dangerStyle.Render(sc.ToStatus)
|
arrow += dangerStyle.Render(sc.ToStatus)
|
||||||
}
|
}
|
||||||
line := fmt.Sprintf(" %s %s", arrow, subtleStyle.Render(ago+" ago"))
|
line := fmt.Sprintf(" %s %s", arrow, subtleStyle.Render(ago+" ago"))
|
||||||
|
if dur := computeOutageDuration(stateChanges, i); dur > 0 {
|
||||||
|
line += " " + warnStyle.Render("outage "+fmtDuration(dur))
|
||||||
|
}
|
||||||
if sc.ErrorReason != "" && sc.ToStatus != "UP" {
|
if sc.ErrorReason != "" && sc.ToStatus != "UP" {
|
||||||
line += " " + dangerStyle.Render(sc.ErrorReason)
|
line += " " + dangerStyle.Render(sc.ErrorReason)
|
||||||
}
|
}
|
||||||
b.WriteString(line + "\n")
|
b.WriteString(line + "\n")
|
||||||
}
|
}
|
||||||
|
b.WriteString(" " + subtleStyle.Render("[h] History") + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
@@ -235,7 +239,7 @@ func (m Model) viewDetailPanel() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [q] Quit"))
|
b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [h] History [q] Quit"))
|
||||||
|
|
||||||
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type historyStats struct {
|
||||||
|
totalEvents int
|
||||||
|
outageCount int
|
||||||
|
totalDowntime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeOutageDuration(changes []models.StateChange, idx int) time.Duration {
|
||||||
|
sc := changes[idx]
|
||||||
|
if sc.ToStatus != "UP" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if idx+1 >= len(changes) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
prev := changes[idx+1]
|
||||||
|
if prev.ToStatus == "UP" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
dur := sc.ChangedAt.Sub(prev.ChangedAt)
|
||||||
|
if dur < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return dur
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeHistoryStats(changes []models.StateChange) historyStats {
|
||||||
|
var s historyStats
|
||||||
|
s.totalEvents = len(changes)
|
||||||
|
for i := range changes {
|
||||||
|
dur := computeOutageDuration(changes, i)
|
||||||
|
if dur > 0 {
|
||||||
|
s.outageCount++
|
||||||
|
s.totalDowntime += dur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func stateChangeSparkline(changes []models.StateChange, width int) string {
|
||||||
|
if len(changes) < 2 || width < 4 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
oldest := changes[len(changes)-1].ChangedAt
|
||||||
|
newest := changes[0].ChangedAt
|
||||||
|
span := newest.Sub(oldest)
|
||||||
|
if span <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets := make([]int, width)
|
||||||
|
for _, sc := range changes {
|
||||||
|
pos := int(float64(sc.ChangedAt.Sub(oldest)) / float64(span) * float64(width-1))
|
||||||
|
if pos >= width {
|
||||||
|
pos = width - 1
|
||||||
|
}
|
||||||
|
if pos < 0 {
|
||||||
|
pos = 0
|
||||||
|
}
|
||||||
|
buckets[pos]++
|
||||||
|
}
|
||||||
|
|
||||||
|
maxVal := 0
|
||||||
|
for _, v := range buckets {
|
||||||
|
if v > maxVal {
|
||||||
|
maxVal = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxVal == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, v := range buckets {
|
||||||
|
if v == 0 {
|
||||||
|
sb.WriteRune('·')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := int(float64(v) / float64(maxVal) * 7)
|
||||||
|
if idx > 7 {
|
||||||
|
idx = 7
|
||||||
|
}
|
||||||
|
ch := string(sparkChars[idx])
|
||||||
|
switch {
|
||||||
|
case v >= 3:
|
||||||
|
sb.WriteString(dangerStyle.Render(ch))
|
||||||
|
case v >= 2:
|
||||||
|
sb.WriteString(warnStyle.Render(ch))
|
||||||
|
default:
|
||||||
|
sb.WriteString(subtleStyle.Render(ch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) buildHistoryContent() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
reasonWidth := m.termWidth - chromePadH - 55
|
||||||
|
if reasonWidth < 10 {
|
||||||
|
reasonWidth = 10
|
||||||
|
}
|
||||||
|
if reasonWidth > 60 {
|
||||||
|
reasonWidth = 60
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, sc := range m.historyChanges {
|
||||||
|
ts := sc.ChangedAt.Format("2006-01-02 15:04")
|
||||||
|
|
||||||
|
arrow := subtleStyle.Render(sc.FromStatus) + " → "
|
||||||
|
if sc.ToStatus == "UP" {
|
||||||
|
arrow += specialStyle.Render(sc.ToStatus)
|
||||||
|
} else {
|
||||||
|
arrow += dangerStyle.Render(sc.ToStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
durStr := ""
|
||||||
|
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
|
||||||
|
durStr = warnStyle.Render("outage " + fmtDuration(dur))
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := ""
|
||||||
|
if sc.ErrorReason != "" && sc.ToStatus != "UP" {
|
||||||
|
reason = dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, " %-18s %s %-12s %s\n", ts, arrow, durStr, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewHistoryPanel() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
header := " " + titleStyle.Render("STATE HISTORY: "+m.historySiteName)
|
||||||
|
header += " " + subtleStyle.Render("[q] Back")
|
||||||
|
b.WriteString(header + "\n")
|
||||||
|
|
||||||
|
divWidth := m.termWidth - chromePadH - 4
|
||||||
|
if divWidth < 40 {
|
||||||
|
divWidth = 40
|
||||||
|
}
|
||||||
|
b.WriteString(" " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n")
|
||||||
|
|
||||||
|
sparkline := stateChangeSparkline(m.historyChanges, divWidth)
|
||||||
|
if sparkline != "" {
|
||||||
|
b.WriteString(" " + sparkline + "\n")
|
||||||
|
b.WriteString(" " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(fmt.Sprintf(" %-18s %-17s %-12s %s\n",
|
||||||
|
subtleStyle.Render("TIME"),
|
||||||
|
subtleStyle.Render("TRANSITION"),
|
||||||
|
subtleStyle.Render("DURATION"),
|
||||||
|
subtleStyle.Render("REASON")))
|
||||||
|
|
||||||
|
if len(m.historyChanges) == 0 {
|
||||||
|
b.WriteString("\n " + subtleStyle.Render("No state changes recorded") + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(m.historyViewport.View())
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n")
|
||||||
|
|
||||||
|
stats := computeHistoryStats(m.historyChanges)
|
||||||
|
parts := []string{fmt.Sprintf("%d events", stats.totalEvents)}
|
||||||
|
if stats.outageCount > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d outages", stats.outageCount))
|
||||||
|
avg := stats.totalDowntime / time.Duration(stats.outageCount)
|
||||||
|
parts = append(parts, "avg outage "+fmtDuration(avg))
|
||||||
|
}
|
||||||
|
b.WriteString(" " + subtleStyle.Render(strings.Join(parts, " │ ")) + "\n")
|
||||||
|
b.WriteString(" " + subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back"))
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
||||||
|
}
|
||||||
@@ -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