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,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())
|
||||
}
|
||||
Reference in New Issue
Block a user