From 60592ef810582e297c6db4e1de415324a5cafd7d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 4 Jun 2026 13:19:30 -0400 Subject: [PATCH] feat(tui): add SLA reporting view Full-screen SLA report accessible via [s] from detail panel. Computes uptime%, downtime, outage count, longest outage, MTTR, and MTBF from state_changes table. Includes daily breakdown with bar chart, switchable time periods (24h/7d/30d/90d), and scrollable viewport. LATE/STALE treated as UP for SLA purposes. --- internal/cluster/cluster_test.go | 5 +- internal/metrics/prometheus_test.go | 5 +- internal/monitor/monitor.go | 8 + internal/monitor/monitor_test.go | 5 +- internal/monitor/sla.go | 225 ++++++++++++++++++++++++++++ internal/monitor/sla_test.go | 165 ++++++++++++++++++++ internal/server/server_test.go | 5 +- internal/store/sqlstore.go | 17 +++ internal/store/store.go | 3 + internal/tui/tui.go | 13 +- internal/tui/update.go | 72 +++++++++ internal/tui/view_dashboard.go | 2 + internal/tui/view_detail.go | 2 +- internal/tui/view_sla.go | 141 +++++++++++++++++ 14 files changed, 661 insertions(+), 7 deletions(-) create mode 100644 internal/monitor/sla.go create mode 100644 internal/monitor/sla_test.go create mode 100644 internal/tui/view_sla.go diff --git a/internal/cluster/cluster_test.go b/internal/cluster/cluster_test.go index d522eb2..26ef9b1 100644 --- a/internal/cluster/cluster_test.go +++ b/internal/cluster/cluster_test.go @@ -73,7 +73,10 @@ func (m *mockStore) GetPreference(string) (string, error) { re func (m *mockStore) SetPreference(string, string) error { return nil } func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil } func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil } -func (m *mockStore) Close() error { return nil } +func (m *mockStore) GetStateChangesSince(int, time.Time) ([]models.StateChange, error) { + return nil, nil +} +func (m *mockStore) Close() error { return nil } // --- Cluster Start Tests --- diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go index 9dc04c6..52409a3 100644 --- a/internal/metrics/prometheus_test.go +++ b/internal/metrics/prometheus_test.go @@ -71,7 +71,10 @@ func (m *mockStore) GetPreference(string) (string, error) { re func (m *mockStore) SetPreference(string, string) error { return nil } func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil } func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil } -func (m *mockStore) Close() error { return nil } +func (m *mockStore) GetStateChangesSince(int, time.Time) ([]models.StateChange, error) { + return nil, nil +} +func (m *mockStore) Close() error { return nil } func TestMetricsHandler(t *testing.T) { ms := &mockStore{ diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index a6d57e7..ea25a7b 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -832,3 +832,11 @@ func (e *Engine) GetStateChanges(siteID int, limit int) []models.StateChange { } return changes } + +func (e *Engine) GetStateChangesSince(siteID int, since time.Time) []models.StateChange { + changes, err := e.db.GetStateChangesSince(siteID, since) + if err != nil { + return nil + } + return changes +} diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index b62e433..585a1ab 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -80,7 +80,10 @@ func (m *mockStore) GetPreference(string) (string, error) { re func (m *mockStore) SetPreference(string, string) error { return nil } func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil } func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil } -func (m *mockStore) Close() error { return nil } +func (m *mockStore) GetStateChangesSince(int, time.Time) ([]models.StateChange, error) { + return nil, nil +} +func (m *mockStore) Close() error { return nil } func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) { m.mu.Lock() diff --git a/internal/monitor/sla.go b/internal/monitor/sla.go new file mode 100644 index 0000000..e7ac242 --- /dev/null +++ b/internal/monitor/sla.go @@ -0,0 +1,225 @@ +package monitor + +import ( + "time" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" +) + +type SLAReport struct { + Window time.Duration + UptimePct float64 + Downtime time.Duration + OutageCount int + LongestOut time.Duration + MTTR time.Duration + MTBF time.Duration +} + +func ComputeSLA(changes []models.StateChange, currentStatus string, window time.Duration) SLAReport { + now := time.Now() + windowStart := now.Add(-window) + + report := SLAReport{Window: window} + + if len(changes) == 0 { + if isDown(currentStatus) { + report.UptimePct = 0 + report.Downtime = window + } else { + report.UptimePct = 100 + } + return report + } + + // Sort changes chronologically (they come in DESC from DB). + sorted := make([]models.StateChange, len(changes)) + copy(sorted, changes) + for i, j := 0, len(sorted)-1; i < j; i, j = i+1, j-1 { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + + // Determine status at window start: last transition before or at windowStart. + statusAtStart := "UP" + for i := len(sorted) - 1; i >= 0; i-- { + if !sorted[i].ChangedAt.After(windowStart) { + statusAtStart = sorted[i].ToStatus + break + } + } + + var upTime, downTime time.Duration + var outages []time.Duration + cursor := windowStart + wasDown := isDown(statusAtStart) + + if wasDown { + report.OutageCount = 1 + } + + var outageStart time.Time + if wasDown { + outageStart = windowStart + } + + for _, sc := range sorted { + if sc.ChangedAt.Before(windowStart) { + continue + } + if sc.ChangedAt.After(now) { + break + } + + seg := sc.ChangedAt.Sub(cursor) + if wasDown { + downTime += seg + } else { + upTime += seg + } + + newDown := isDown(sc.ToStatus) + if !wasDown && newDown { + report.OutageCount++ + outageStart = sc.ChangedAt + } + if wasDown && !newDown { + dur := sc.ChangedAt.Sub(outageStart) + outages = append(outages, dur) + } + + wasDown = newDown + cursor = sc.ChangedAt + } + + // Account for time from last change to now. + remaining := now.Sub(cursor) + if wasDown { + downTime += remaining + dur := now.Sub(outageStart) + outages = append(outages, dur) + } else { + upTime += remaining + } + + total := upTime + downTime + if total > 0 { + report.UptimePct = float64(upTime) / float64(total) * 100 + } else { + report.UptimePct = 100 + } + report.Downtime = downTime + + if len(outages) > 0 { + var totalOutage time.Duration + for _, d := range outages { + totalOutage += d + if d > report.LongestOut { + report.LongestOut = d + } + } + report.MTTR = totalOutage / time.Duration(len(outages)) + } + + if report.OutageCount > 0 && upTime > 0 { + report.MTBF = upTime / time.Duration(report.OutageCount) + } + + return report +} + +func ComputeDailyBreakdown(changes []models.StateChange, currentStatus string, days int) []DayReport { + now := time.Now() + reports := make([]DayReport, days) + + for i := 0; i < days; i++ { + dayEnd := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(-time.Duration(i) * 24 * time.Hour) + if i == 0 { + dayEnd = now + } + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(-time.Duration(i) * 24 * time.Hour) + if i > 0 { + dayEnd = dayStart.Add(24 * time.Hour) + } + + windowChanges := filterChangesForWindow(changes, dayStart, dayEnd) + + statusAtStart := inferStatusAt(changes, dayStart) + sla := computeSLAForWindow(windowChanges, statusAtStart, dayStart, dayEnd) + + reports[i] = DayReport{ + Date: dayStart, + UptimePct: sla, + } + } + + return reports +} + +type DayReport struct { + Date time.Time + UptimePct float64 +} + +func isDown(status string) bool { + return status == "DOWN" || status == "SSL EXP" +} + +func filterChangesForWindow(changes []models.StateChange, start, end time.Time) []models.StateChange { + var filtered []models.StateChange + for _, sc := range changes { + if !sc.ChangedAt.Before(start) && sc.ChangedAt.Before(end) { + filtered = append(filtered, sc) + } + } + return filtered +} + +func inferStatusAt(changes []models.StateChange, at time.Time) string { + // Changes come DESC from DB. Walk backwards to find last change before `at`. + for _, sc := range changes { + if !sc.ChangedAt.After(at) { + return sc.ToStatus + } + } + return "UP" +} + +func computeSLAForWindow(changes []models.StateChange, statusAtStart string, start, end time.Time) float64 { + // Sort chronologically. + sorted := make([]models.StateChange, len(changes)) + copy(sorted, changes) + for i, j := 0, len(sorted)-1; i < j; i, j = i+1, j-1 { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + + var upTime, downTime time.Duration + cursor := start + wasDown := isDown(statusAtStart) + + for _, sc := range sorted { + if sc.ChangedAt.Before(start) || !sc.ChangedAt.Before(end) { + continue + } + seg := sc.ChangedAt.Sub(cursor) + if wasDown { + downTime += seg + } else { + upTime += seg + } + wasDown = isDown(sc.ToStatus) + cursor = sc.ChangedAt + } + + remaining := end.Sub(cursor) + if wasDown { + downTime += remaining + } else { + upTime += remaining + } + + total := upTime + downTime + if total <= 0 { + return 100 + } + return float64(upTime) / float64(total) * 100 +} diff --git a/internal/monitor/sla_test.go b/internal/monitor/sla_test.go new file mode 100644 index 0000000..7bf23a0 --- /dev/null +++ b/internal/monitor/sla_test.go @@ -0,0 +1,165 @@ +package monitor + +import ( + "math" + "testing" + "time" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" +) + +func TestComputeSLA_NoChanges_CurrentlyUp(t *testing.T) { + r := ComputeSLA(nil, "UP", 24*time.Hour) + if r.UptimePct != 100 { + t.Errorf("expected 100%% uptime, got %.2f%%", r.UptimePct) + } + if r.Downtime != 0 { + t.Errorf("expected 0 downtime, got %v", r.Downtime) + } +} + +func TestComputeSLA_NoChanges_CurrentlyDown(t *testing.T) { + r := ComputeSLA(nil, "DOWN", 24*time.Hour) + if r.UptimePct != 0 { + t.Errorf("expected 0%% uptime, got %.2f%%", r.UptimePct) + } +} + +func TestComputeSLA_SingleOutage(t *testing.T) { + now := time.Now() + // DOWN 2 hours ago, recovered 1 hour ago → 1 hour downtime in 24h window + changes := []models.StateChange{ + {ToStatus: "UP", ChangedAt: now.Add(-1 * time.Hour)}, + {ToStatus: "DOWN", FromStatus: "UP", ChangedAt: now.Add(-2 * time.Hour)}, + } + + r := ComputeSLA(changes, "UP", 24*time.Hour) + + if r.OutageCount != 1 { + t.Errorf("expected 1 outage, got %d", r.OutageCount) + } + + expectedDowntime := 1 * time.Hour + if absDuration(r.Downtime-expectedDowntime) > time.Minute { + t.Errorf("expected ~1h downtime, got %v", r.Downtime) + } + + expectedPct := float64(23) / float64(24) * 100 + if math.Abs(r.UptimePct-expectedPct) > 0.5 { + t.Errorf("expected ~%.1f%% uptime, got %.2f%%", expectedPct, r.UptimePct) + } + + if r.LongestOut < 55*time.Minute || r.LongestOut > 65*time.Minute { + t.Errorf("expected longest outage ~1h, got %v", r.LongestOut) + } +} + +func TestComputeSLA_CurrentlyDown(t *testing.T) { + now := time.Now() + // Went down 3 hours ago, still down + changes := []models.StateChange{ + {ToStatus: "DOWN", FromStatus: "UP", ChangedAt: now.Add(-3 * time.Hour)}, + } + + r := ComputeSLA(changes, "DOWN", 24*time.Hour) + + if r.OutageCount != 1 { + t.Errorf("expected 1 outage, got %d", r.OutageCount) + } + + expectedDowntime := 3 * time.Hour + if absDuration(r.Downtime-expectedDowntime) > time.Minute { + t.Errorf("expected ~3h downtime, got %v", r.Downtime) + } +} + +func TestComputeSLA_MultipleOutages(t *testing.T) { + now := time.Now() + // Two outages: 6h-5h ago and 2h-1h ago + changes := []models.StateChange{ + {ToStatus: "UP", ChangedAt: now.Add(-1 * time.Hour)}, + {ToStatus: "DOWN", FromStatus: "UP", ChangedAt: now.Add(-2 * time.Hour)}, + {ToStatus: "UP", ChangedAt: now.Add(-5 * time.Hour)}, + {ToStatus: "DOWN", FromStatus: "UP", ChangedAt: now.Add(-6 * time.Hour)}, + } + + r := ComputeSLA(changes, "UP", 24*time.Hour) + + if r.OutageCount != 2 { + t.Errorf("expected 2 outages, got %d", r.OutageCount) + } + + expectedDowntime := 2 * time.Hour + if absDuration(r.Downtime-expectedDowntime) > time.Minute { + t.Errorf("expected ~2h downtime, got %v", r.Downtime) + } + + if r.MTTR < 55*time.Minute || r.MTTR > 65*time.Minute { + t.Errorf("expected MTTR ~1h, got %v", r.MTTR) + } +} + +func TestComputeSLA_LateNotDown(t *testing.T) { + now := time.Now() + // LATE for 2 hours is not downtime + changes := []models.StateChange{ + {ToStatus: "UP", ChangedAt: now.Add(-1 * time.Hour)}, + {ToStatus: "LATE", FromStatus: "UP", ChangedAt: now.Add(-3 * time.Hour)}, + } + + r := ComputeSLA(changes, "UP", 24*time.Hour) + + if r.OutageCount != 0 { + t.Errorf("expected 0 outages for LATE, got %d", r.OutageCount) + } + if r.UptimePct != 100 { + t.Errorf("expected 100%% uptime (LATE is not down), got %.2f%%", r.UptimePct) + } +} + +func TestComputeDailyBreakdown(t *testing.T) { + now := time.Now() + changes := []models.StateChange{ + {ToStatus: "UP", ChangedAt: now.Add(-1 * time.Hour)}, + {ToStatus: "DOWN", FromStatus: "UP", ChangedAt: now.Add(-2 * time.Hour)}, + } + + days := ComputeDailyBreakdown(changes, "UP", 7) + + if len(days) != 7 { + t.Fatalf("expected 7 days, got %d", len(days)) + } + + // Today should have less than 100% uptime + if days[0].UptimePct >= 100 { + t.Errorf("expected today < 100%%, got %.2f%%", days[0].UptimePct) + } +} + +func TestIsDown(t *testing.T) { + if !isDown("DOWN") { + t.Error("DOWN should be down") + } + if !isDown("SSL EXP") { + t.Error("SSL EXP should be down") + } + if isDown("UP") { + t.Error("UP should not be down") + } + if isDown("LATE") { + t.Error("LATE should not be down") + } + if isDown("STALE") { + t.Error("STALE should not be down") + } + if isDown("PENDING") { + t.Error("PENDING should not be down") + } +} + +func absDuration(d time.Duration) time.Duration { + if d < 0 { + return -d + } + return d +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f5d7bf6..73f8b84 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -82,7 +82,10 @@ func (m *mockStore) GetPreference(string) (string, error) { re func (m *mockStore) SetPreference(string, string) error { return nil } func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil } func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil } -func (m *mockStore) Close() error { return nil } +func (m *mockStore) GetStateChangesSince(int, time.Time) ([]models.StateChange, error) { + return nil, nil +} +func (m *mockStore) Close() error { return nil } func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{ diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index b936685..c053e3e 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -370,6 +370,23 @@ func (s *SQLStore) GetStateChanges(siteID int, limit int) ([]models.StateChange, return changes, rows.Err() } +func (s *SQLStore) GetStateChangesSince(siteID int, since time.Time) ([]models.StateChange, error) { + rows, err := s.db.Query(s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? AND changed_at >= ? ORDER BY changed_at DESC"), siteID, since) + if err != nil { + return nil, err + } + defer rows.Close() + var changes []models.StateChange + for rows.Next() { + var sc models.StateChange + if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil { + return changes, err + } + changes = append(changes, sc) + } + return changes, rows.Err() +} + func (s *SQLStore) SaveCheck(siteID int, latencyNs int64, isUp bool) error { return s.SaveCheckFromNode(siteID, "", latencyNs, isUp) } diff --git a/internal/store/store.go b/internal/store/store.go index ced24c3..0389a1e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,6 +1,8 @@ package store import ( + "time" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" ) @@ -41,6 +43,7 @@ type Store interface { // State Changes SaveStateChange(siteID int, fromStatus, toStatus, errorReason string) error GetStateChanges(siteID int, limit int) ([]models.StateChange, error) + GetStateChangesSince(siteID int, since time.Time) ([]models.StateChange, error) // Nodes RegisterNode(node models.ProbeNode) error diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e2c0bc1..b5e4729 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -73,6 +73,7 @@ const ( stateConfirmDelete stateFormMaint stateHistory + stateSLA ) type Model struct { @@ -98,8 +99,16 @@ type Model struct { historyViewport viewport.Model historyChanges []models.StateChange historySiteName string - isAdmin bool - zones *zone.Manager + + slaViewport viewport.Model + slaReport monitor.SLAReport + slaDailyBreakdown []monitor.DayReport + slaSiteName string + slaSiteID int + slaPeriodIdx int + + isAdmin bool + zones *zone.Manager deleteID int deleteName string diff --git a/internal/tui/update.go b/internal/tui/update.go index 84e2bbb..3527ded 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -4,6 +4,8 @@ import ( "fmt" "time" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" @@ -128,6 +130,8 @@ func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter) m.historyViewport.Width = msg.Width - chromePadH m.historyViewport.Height = msg.Height - 10 + m.slaViewport.Width = msg.Width - chromePadH + m.slaViewport.Height = msg.Height - 16 return m, tea.ClearScreen } @@ -149,6 +153,15 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } return m, nil } + if m.state == stateSLA { + switch msg.Button { + case tea.MouseButtonWheelUp: + m.slaViewport.ScrollUp(3) + case tea.MouseButtonWheelDown: + m.slaViewport.ScrollDown(3) + } + return m, nil + } if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers { return m, nil } @@ -204,6 +217,8 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleDetailKey(msg) case stateHistory: return m.handleHistoryKey(msg) + case stateSLA: + return m.handleSLAKey(msg) case stateAlertDetail: return m.handleAlertDetailKey(msg) case stateDashboard, stateLogs, stateUsers: @@ -261,12 +276,69 @@ func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.historyViewport.GotoTop() m.state = stateHistory } + case "s": + if m.cursor < len(m.sites) { + m.openSLAView(m.sites[m.cursor]) + } case "q": return m, tea.Quit } return m, nil } +func (m *Model) handleSLAKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + m.state = stateDetail + case "1", "2", "3", "4": + idx := int(msg.String()[0]-'0') - 1 + if idx >= 0 && idx < len(slaPeriods) { + m.slaPeriodIdx = idx + m.recomputeSLA() + } + case "up", "k": + m.slaViewport.ScrollUp(1) + case "down", "j": + m.slaViewport.ScrollDown(1) + case "pgup": + m.slaViewport.HalfPageUp() + case "pgdown": + m.slaViewport.HalfPageDown() + case "ctrl+c": + return m, tea.Quit + } + return m, nil +} + +func (m *Model) openSLAView(site models.Site) { + m.slaSiteName = site.Name + m.slaSiteID = site.ID + m.slaPeriodIdx = 2 // default 30d + m.recomputeSLA() + m.state = stateSLA +} + +func (m *Model) recomputeSLA() { + period := slaPeriods[m.slaPeriodIdx] + since := time.Now().Add(-period.duration) + changes := m.engine.GetStateChangesSince(m.slaSiteID, since) + + var currentStatus string + if m.cursor < len(m.sites) { + currentStatus = m.sites[m.cursor].Status + } + + m.slaReport = monitor.ComputeSLA(changes, currentStatus, period.duration) + m.slaDailyBreakdown = monitor.ComputeDailyBreakdown(changes, currentStatus, period.days) + + m.slaViewport = viewport.New( + m.termWidth-chromePadH, + m.termHeight-16, + ) + m.slaViewport.SetContent(m.buildSLADailyContent()) + m.slaViewport.GotoTop() +} + func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "esc": diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 08fdaac..3507079 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -98,6 +98,8 @@ func (m Model) View() string { return m.viewDetailPanel() case stateHistory: return m.viewHistoryPanel() + case stateSLA: + return m.viewSLAPanel() case stateAlertDetail: return m.viewAlertDetailPanel() default: diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index 25b5115..20c8542 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -243,7 +243,7 @@ func (m Model) viewDetailPanel() string { } b.WriteString("\n\n") - b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [h] History [q] Quit")) + b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [h] History [s] SLA [q] Quit")) return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) } diff --git a/internal/tui/view_sla.go b/internal/tui/view_sla.go new file mode 100644 index 0000000..cd6bac7 --- /dev/null +++ b/internal/tui/view_sla.go @@ -0,0 +1,141 @@ +package tui + +import ( + "fmt" + "math" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +var slaPeriods = []struct { + label string + key string + duration time.Duration + days int +}{ + {"24h", "1", 24 * time.Hour, 1}, + {"7d", "2", 7 * 24 * time.Hour, 7}, + {"30d", "3", 30 * 24 * time.Hour, 30}, + {"90d", "4", 90 * 24 * time.Hour, 90}, +} + +func (m Model) viewSLAPanel() string { + var b strings.Builder + + header := " " + titleStyle.Render("SLA REPORT: "+m.slaSiteName) + 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") + + period := slaPeriods[m.slaPeriodIdx] + b.WriteString(" " + subtleStyle.Render("Period: Last "+period.label) + "\n\n") + + r := m.slaReport + + // Uptime bar + barWidth := divWidth - 30 + if barWidth < 10 { + barWidth = 10 + } + bar := uptimeBar(r.UptimePct, barWidth) + uptimeColor := specialStyle + if r.UptimePct < 99.9 { + uptimeColor = warnStyle + } + if r.UptimePct < 99.0 { + uptimeColor = dangerStyle + } + fmt.Fprintf(&b, " %-14s %s %s\n", subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar) + fmt.Fprintf(&b, " %-14s %s\n", subtleStyle.Render("Downtime"), fmtDuration(r.Downtime)) + fmt.Fprintf(&b, " %-14s %d\n", subtleStyle.Render("Outages"), r.OutageCount) + + if r.OutageCount > 0 { + fmt.Fprintf(&b, " %-14s %s\n", subtleStyle.Render("Longest"), fmtDuration(r.LongestOut)) + fmt.Fprintf(&b, " %-14s %s\n", subtleStyle.Render("MTTR"), fmtDuration(r.MTTR)) + fmt.Fprintf(&b, " %-14s %s\n", subtleStyle.Render("MTBF"), fmtDuration(r.MTBF)) + } + + b.WriteString("\n " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n") + + if len(m.slaDailyBreakdown) > 0 { + b.WriteString(m.slaViewport.View()) + } + + b.WriteString("\n " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n") + + var keys []string + for i, p := range slaPeriods { + label := fmt.Sprintf("[%s] %s", p.key, p.label) + if i == m.slaPeriodIdx { + keys = append(keys, titleStyle.Render(label)) + } else { + keys = append(keys, subtleStyle.Render(label)) + } + } + b.WriteString(" " + strings.Join(keys, " ")) + b.WriteString(" " + subtleStyle.Render("[j/k/↑/↓] Scroll")) + + return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) +} + +func (m Model) buildSLADailyContent() string { + var b strings.Builder + + barWidth := m.termWidth - chromePadH - 30 + if barWidth < 10 { + barWidth = 10 + } + + b.WriteString(" " + subtleStyle.Render("DAILY BREAKDOWN") + "\n") + for _, day := range m.slaDailyBreakdown { + dateStr := day.Date.Format("Jan 02") + bar := uptimeBar(day.UptimePct, barWidth) + pctStr := fmtPct(day.UptimePct) + "%" + + color := specialStyle + if day.UptimePct < 99.9 { + color = warnStyle + } + if day.UptimePct < 99.0 { + color = dangerStyle + } + + fmt.Fprintf(&b, " %-8s %s %s\n", subtleStyle.Render(dateStr), bar, color.Render(pctStr)) + } + + return b.String() +} + +func uptimeBar(pct float64, width int) string { + filled := int(math.Round(pct / 100 * float64(width))) + if filled > width { + filled = width + } + if filled < 0 { + filled = 0 + } + empty := width - filled + + bar := specialStyle.Render(strings.Repeat("█", filled)) + if empty > 0 { + bar += subtleStyle.Render(strings.Repeat("░", empty)) + } + return bar +} + +func fmtPct(pct float64) string { + if pct == 100 { + return "100.00" + } + if pct >= 99.99 { + return fmt.Sprintf("%.3f", pct) + } + return fmt.Sprintf("%.2f", pct) +}