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
|
||||
stateConfirmDelete
|
||||
stateFormMaint
|
||||
stateHistory
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
@@ -91,8 +92,12 @@ type Model struct {
|
||||
|
||||
logViewport viewport.Model
|
||||
logFilterImportant bool
|
||||
isAdmin bool
|
||||
zones *zone.Manager
|
||||
|
||||
historyViewport viewport.Model
|
||||
historyChanges []models.StateChange
|
||||
historySiteName string
|
||||
isAdmin bool
|
||||
zones *zone.Manager
|
||||
|
||||
deleteID int
|
||||
deleteName string
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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