f00acbc280
Replace ~150 bare status string comparisons with typed models.Status constants (StatusUp, StatusDown, StatusPending, StatusLate, StatusStale, StatusSSLExp). Single IsBroken() method replaces the duplicated isBroken lambda in monitor.go and isDown function in sla.go. Adding a new status value (e.g. DEGRADED) now requires one constant definition instead of grep-and-pray across 16 files. CheckResult.Status stays string — the checker is the boundary between raw protocol results and typed status. Cast happens at the edge in handleStatusChange.
193 lines
4.5 KiB
Go
193 lines
4.5 KiB
Go
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 != string(models.StatusUp) {
|
|
return 0
|
|
}
|
|
if idx+1 >= len(changes) {
|
|
return 0
|
|
}
|
|
prev := changes[idx+1]
|
|
if prev.ToStatus == string(models.StatusUp) {
|
|
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
|
|
}
|
|
|
|
var stateChangeChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
|
|
|
func (m Model) 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(stateChangeChars[idx])
|
|
switch {
|
|
case v >= 3:
|
|
sb.WriteString(m.st.dangerStyle.Render(ch))
|
|
case v >= 2:
|
|
sb.WriteString(m.st.warnStyle.Render(ch))
|
|
default:
|
|
sb.WriteString(m.st.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 := m.st.subtleStyle.Render(sc.FromStatus) + " → "
|
|
switch sc.ToStatus {
|
|
case string(models.StatusUp):
|
|
arrow += m.st.specialStyle.Render(sc.ToStatus)
|
|
case string(models.StatusLate):
|
|
arrow += m.st.warnStyle.Render(sc.ToStatus)
|
|
case string(models.StatusStale):
|
|
arrow += m.st.staleStyle.Render(sc.ToStatus)
|
|
default:
|
|
arrow += m.st.dangerStyle.Render(sc.ToStatus)
|
|
}
|
|
|
|
durStr := ""
|
|
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
|
|
durStr = m.st.warnStyle.Render("outage " + fmtDuration(dur))
|
|
}
|
|
|
|
reason := ""
|
|
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
|
|
reason = m.st.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 := " " + m.st.titleStyle.Render("STATE HISTORY: "+m.historySiteName)
|
|
header += " " + m.st.subtleStyle.Render("[q] Back")
|
|
b.WriteString(header + "\n")
|
|
|
|
divWidth := m.dividerWidth()
|
|
b.WriteString(m.divider() + "\n")
|
|
|
|
sparkline := m.stateChangeSparkline(m.historyChanges, divWidth)
|
|
if sparkline != "" {
|
|
b.WriteString(" " + sparkline + "\n")
|
|
b.WriteString(m.divider() + "\n")
|
|
}
|
|
|
|
fmt.Fprintf(&b, " %-18s %-17s %-12s %s\n",
|
|
m.st.subtleStyle.Render("TIME"),
|
|
m.st.subtleStyle.Render("TRANSITION"),
|
|
m.st.subtleStyle.Render("DURATION"),
|
|
m.st.subtleStyle.Render("REASON"))
|
|
|
|
if len(m.historyChanges) == 0 {
|
|
b.WriteString("\n " + m.st.subtleStyle.Render("No state changes recorded") + "\n")
|
|
} else {
|
|
b.WriteString(m.historyViewport.View())
|
|
}
|
|
|
|
b.WriteString("\n" + m.divider() + "\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(" " + m.st.subtleStyle.Render(strings.Join(parts, " │ ")) + "\n")
|
|
b.WriteString(" " + m.st.subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back"))
|
|
|
|
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
|
}
|