diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 05b7a3c..00ca735 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -97,6 +97,12 @@ const ( panelMaint = 3 ) +const ( + detailDefault = 0 + detailSLA = 1 + detailHistory = 2 +) + const ( sortStatus = 0 sortName = 1 @@ -138,6 +144,7 @@ type Model struct { termHeight int contentWidth int focusedPanel int + detailMode int logScrollOffset int editID int editToken string diff --git a/internal/tui/update.go b/internal/tui/update.go index 42c0f6c..8f64d04 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -771,6 +771,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.syncSelectedID() if m.detailOpen && m.cursor < len(m.sites) { + m.detailMode = detailDefault return m, m.loadDetailCmd(m.sites[m.cursor].ID) } } @@ -791,6 +792,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.syncSelectedID() if m.detailOpen && m.cursor < len(m.sites) { + m.detailMode = detailDefault return m, m.loadDetailCmd(m.sites[m.cursor].ID) } } @@ -870,8 +872,11 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "esc": if m.focusedPanel != panelMonitors { m.focusedPanel = panelMonitors + } else if m.detailOpen && m.detailMode != detailDefault { + m.detailMode = detailDefault } else if m.detailOpen { m.detailOpen = false + m.detailMode = detailDefault m.recalcLayout() st := m.store ctx := m.ctx @@ -885,17 +890,25 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.historySiteName = site.Name m.historySiteID = site.ID m.historyChanges = nil - m.historyViewport = viewport.New( - m.termWidth-chromePadH, - m.termHeight-10, - ) - m.historyViewport.SetContent("\n Loading state history...") - m.state = stateHistory + m.detailMode = detailHistory return m, m.loadHistoryCmd(site.ID) } case "s": if m.detailOpen && m.cursor < len(m.sites) { - return m, m.openSLAView(m.sites[m.cursor]) + site := m.sites[m.cursor] + m.slaSiteName = site.Name + m.slaSiteID = site.ID + m.slaPeriodIdx = 2 + m.detailMode = detailSLA + return m, m.loadSLACmd(site.ID, m.slaPeriodIdx) + } + case "1", "2", "3", "4": + if m.detailOpen && m.detailMode == detailSLA { + idx := int(msg.String()[0]-'0') - 1 + if idx >= 0 && idx < len(slaPeriods) { + m.slaPeriodIdx = idx + return m, m.loadSLACmd(m.slaSiteID, idx) + } } case "x": if m.focusedPanel == panelMaint { diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 20ae20f..fdc0497 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -186,12 +186,18 @@ func (m Model) viewMonitorsLayout() string { } topParts = append(topParts, monPanel) if showDetail { - site := "" + title := "" if m.cursor < len(m.sites) { - site = m.sites[m.cursor].Name + title = m.sites[m.cursor].Name + } + switch m.detailMode { + case detailSLA: + title = "SLA · " + title + case detailHistory: + title = "History · " + title } detail := m.viewDetailInline(detailW - 2) - detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, detailW, m.focusedPanel == panelDetail)) + detailPanel := m.zones.Mark("panel-detail", m.titledPanel(title, detail, detailW, m.focusedPanel == panelDetail)) topParts = append(topParts, detailPanel) } @@ -279,6 +285,10 @@ func (m Model) renderFooter(_ dashboardStats) string { var keys string if m.focusedPanel == panelMaint { keys = "[n]New [x]End [d]Del [Esc]Back [S]Settings [T]Theme [q]Quit" + } else if m.detailOpen && m.detailMode == detailSLA { + keys = "[1-4]Period [Esc]Back [S]Settings [T]Theme [q]Quit" + } else if m.detailOpen && m.detailMode == detailHistory { + keys = "[Esc]Back [S]Settings [T]Theme [q]Quit" } else if m.detailOpen { keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit" } else { diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index eb4ae7f..dbb4127 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -14,9 +14,16 @@ func (m Model) viewDetailInline(width int) string { if m.cursor >= len(m.sites) { return "" } - site := m.sites[m.cursor] - hist, _ := m.engine.GetHistory(site.ID) - return m.viewDetailSidebar(site, hist, width) + switch m.detailMode { + case detailSLA: + return m.viewSLASidebar(width) + case detailHistory: + return m.viewHistorySidebar(width) + default: + site := m.sites[m.cursor] + hist, _ := m.engine.GetHistory(site.ID) + return m.viewDetailSidebar(site, hist, width) + } } func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width int) string { @@ -206,7 +213,7 @@ func (m Model) detailTypeLine(site models.Site) []string { } func (m Model) detailKeys() string { - return m.st.subtleStyle.Render("[e] Edit [h] History [s] SLA [q/Esc] Back") + return m.st.subtleStyle.Render("[e] Edit [h] History [s] SLA [Esc] Back") } func (m Model) fmtStatusWord(status string) string { @@ -227,3 +234,141 @@ func (m Model) fmtStatusWord(status string) string { return m.st.subtleStyle.Render(status) } } + +func (m Model) viewSLASidebar(width int) string { + var b strings.Builder + label := m.st.subtleStyle + innerW := width - 4 + if innerW < 20 { + innerW = 20 + } + + period := slaPeriods[m.slaPeriodIdx] + b.WriteString(" " + label.Render("Period: Last "+period.label) + "\n\n") + + r := m.slaReport + barWidth := innerW - 25 + if barWidth < 10 { + barWidth = 10 + } + bar := m.uptimeBar(r.UptimePct, barWidth) + uptimeColor := m.st.specialStyle + if r.UptimePct < uptimeExcellentPct { + uptimeColor = m.st.warnStyle + } + if r.UptimePct < uptimeGoodPct { + uptimeColor = m.st.dangerStyle + } + fmt.Fprintf(&b, " %-14s %s %s\n", label.Render("Uptime"), uptimeColor.Render(fmtPct(r.UptimePct)+"%"), bar) + fmt.Fprintf(&b, " %-14s %s\n", label.Render("Downtime"), fmtDuration(r.Downtime)) + fmt.Fprintf(&b, " %-14s %d\n", label.Render("Outages"), r.OutageCount) + + if r.OutageCount > 0 { + fmt.Fprintf(&b, " %-14s %s\n", label.Render("Longest"), fmtDuration(r.LongestOut)) + fmt.Fprintf(&b, " %-14s %s\n", label.Render("MTTR"), fmtDuration(r.MTTR)) + fmt.Fprintf(&b, " %-14s %s\n", label.Render("MTBF"), fmtDuration(r.MTBF)) + } + + b.WriteString("\n") + + if len(m.slaDailyBreakdown) > 0 { + b.WriteString(" " + m.st.titleStyle.Render("DAILY BREAKDOWN") + "\n") + dayBarW := innerW - 20 + if dayBarW < 10 { + dayBarW = 10 + } + for _, day := range m.slaDailyBreakdown { + dateStr := day.Date.Format("Jan 02") + dayBar := m.uptimeBar(day.UptimePct, dayBarW) + pctStr := fmtPct(day.UptimePct) + "%" + color := m.st.specialStyle + if day.UptimePct < uptimeExcellentPct { + color = m.st.warnStyle + } + if day.UptimePct < uptimeGoodPct { + color = m.st.dangerStyle + } + fmt.Fprintf(&b, " %-8s %s %s\n", label.Render(dateStr), dayBar, color.Render(pctStr)) + } + } + + b.WriteString("\n") + + var keys []string + for i, p := range slaPeriods { + k := fmt.Sprintf("[%s] %s", p.key, p.label) + if i == m.slaPeriodIdx { + keys = append(keys, m.st.titleStyle.Render(k)) + } else { + keys = append(keys, label.Render(k)) + } + } + b.WriteString(" " + strings.Join(keys, " ") + "\n") + b.WriteString(" " + label.Render("[Esc] Back") + "\n") + + return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) +} + +func (m Model) viewHistorySidebar(width int) string { + var b strings.Builder + label := m.st.subtleStyle + innerW := width - 4 + if innerW < 20 { + innerW = 20 + } + + sparkline := m.stateChangeSparkline(m.historyChanges, innerW) + if sparkline != "" { + b.WriteString(" " + sparkline + "\n\n") + } + + if len(m.historyChanges) == 0 { + b.WriteString(" " + label.Render("No state changes recorded") + "\n") + } else { + reasonW := innerW - 45 + if reasonW < 10 { + reasonW = 10 + } + for i, sc := range m.historyChanges { + ts := sc.ChangedAt.Format("01/02 15:04") + + arrow := label.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(fmtDuration(dur)) + } + + reason := "" + if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) { + reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW)) + } + + fmt.Fprintf(&b, " %-12s %s %s %s\n", ts, arrow, durStr, reason) + } + } + + b.WriteString("\n") + + stats := computeHistoryStats(m.historyChanges) + statParts := []string{fmt.Sprintf("%d events", stats.totalEvents)} + if stats.outageCount > 0 { + statParts = append(statParts, fmt.Sprintf("%d outages", stats.outageCount)) + avg := stats.totalDowntime / time.Duration(stats.outageCount) + statParts = append(statParts, "avg "+fmtDuration(avg)) + } + b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n") + b.WriteString(" " + label.Render("[Esc] Back") + "\n") + + return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) +}