feat(tui): add state change history view with outage duration
CI / test (pull_request) Successful in 2m30s
CI / lint (pull_request) Failing after 51s
CI / vulncheck (pull_request) Successful in 46s

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:
2026-06-03 19:49:10 -04:00
parent c0ad51af9c
commit bc661f5207
6 changed files with 422 additions and 4 deletions
+5
View File
@@ -70,6 +70,7 @@ const (
stateFormUser
stateConfirmDelete
stateFormMaint
stateHistory
)
type Model struct {
@@ -91,6 +92,10 @@ type Model struct {
logViewport viewport.Model
logFilterImportant bool
historyViewport viewport.Model
historyChanges []models.StateChange
historySiteName string
isAdmin bool
zones *zone.Manager
+48
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"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.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter)
m.historyViewport.Width = msg.Width - chromePadH
m.historyViewport.Height = msg.Height - 10
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) {
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 {
return m, nil
}
@@ -187,6 +198,8 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.state {
case stateDetail:
return m.handleDetailKey(msg)
case stateHistory:
return m.handleHistoryKey(msg)
case stateAlertDetail:
return m.handleAlertDetailKey(msg)
case stateDashboard, stateLogs, stateUsers:
@@ -229,12 +242,47 @@ func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "i", "esc":
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":
return m, tea.Quit
}
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) {
switch msg.String() {
case "i", "esc":
+2
View File
@@ -96,6 +96,8 @@ func (m Model) View() string {
return ""
case stateDetail:
return m.viewDetailPanel()
case stateHistory:
return m.viewHistoryPanel()
case stateAlertDetail:
return m.viewAlertDetailPanel()
default:
+6 -2
View File
@@ -176,7 +176,7 @@ func (m Model) viewDetailPanel() string {
stateChanges := m.engine.GetStateChanges(site.ID, 5)
if len(stateChanges) > 0 {
b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n")
for _, sc := range stateChanges {
for i, sc := range stateChanges {
ago := fmtDuration(time.Since(sc.ChangedAt))
arrow := subtleStyle.Render(sc.FromStatus) + " → "
if sc.ToStatus == "UP" {
@@ -185,11 +185,15 @@ func (m Model) viewDetailPanel() string {
arrow += dangerStyle.Render(sc.ToStatus)
}
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" {
line += " " + dangerStyle.Render(sc.ErrorReason)
}
b.WriteString(line + "\n")
}
b.WriteString(" " + subtleStyle.Render("[h] History") + "\n")
}
b.WriteString("\n")
@@ -235,7 +239,7 @@ func (m Model) viewDetailPanel() string {
}
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())
}
+188
View File
@@ -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())
}
+171
View File
@@ -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)
}
})
}