diff --git a/internal/tui/histogram.go b/internal/tui/histogram.go new file mode 100644 index 0000000..3c547cf --- /dev/null +++ b/internal/tui/histogram.go @@ -0,0 +1,89 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +type latencyBucket struct { + label string + min int64 + max int64 + count int +} + +func (m Model) latencyHistogram(latencies []time.Duration, statuses []bool, width int) string { + if len(latencies) == 0 || width < 30 { + return "" + } + + buckets := []latencyBucket{ + {"0-50ms", 0, 50, 0}, + {"50-100ms", 50, 100, 0}, + {"100-200ms", 100, 200, 0}, + {"200-500ms", 200, 500, 0}, + {"500ms+", 500, 999999, 0}, + } + + for i, l := range latencies { + if i < len(statuses) && !statuses[i] { + continue + } + ms := l.Milliseconds() + for j := range buckets { + if ms >= buckets[j].min && ms < buckets[j].max { + buckets[j].count++ + break + } + } + } + + maxCount := 0 + for _, b := range buckets { + if b.count > maxCount { + maxCount = b.count + } + } + if maxCount == 0 { + return "" + } + + labelW := 10 + countW := len(fmt.Sprintf("%d", maxCount)) + barW := width - labelW - countW - 4 + if barW < 5 { + barW = 5 + } + + var sb strings.Builder + for _, b := range buckets { + fill := 0 + if maxCount > 0 { + fill = b.count * barW / maxCount + } + + var barColor lipgloss.Style + switch { + case b.min < 100: + barColor = m.st.specialStyle + case b.min < 500: + barColor = m.st.warnStyle + default: + barColor = m.st.dangerStyle + } + + bar := barColor.Render(strings.Repeat("█", fill)) + if fill < barW { + bar += m.st.subtleStyle.Render(strings.Repeat("░", barW-fill)) + } + + label := fmt.Sprintf("%*s", labelW, b.label) + count := fmt.Sprintf("%*d", countW, b.count) + sb.WriteString(" " + m.st.subtleStyle.Render(label) + " " + bar + " " + count + "\n") + } + + return sb.String() +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 896fefa..e33f03a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -119,16 +119,17 @@ const ( type sessionState int const ( - stateDashboard sessionState = 0 - stateLogs sessionState = 1 - stateAlertDetail sessionState = 3 - stateFormSite sessionState = 4 - stateFormAlert sessionState = 5 - stateFormUser sessionState = 6 - stateConfirmDelete sessionState = 7 - stateFormMaint sessionState = 8 - stateSettings sessionState = 11 - stateMaintDetail sessionState = 12 + stateDashboard sessionState = 0 + stateLogs sessionState = 1 + stateDetailFullscreen sessionState = 2 + stateAlertDetail sessionState = 3 + stateFormSite sessionState = 4 + stateFormAlert sessionState = 5 + stateFormUser sessionState = 6 + stateConfirmDelete sessionState = 7 + stateFormMaint sessionState = 8 + stateSettings sessionState = 11 + stateMaintDetail sessionState = 12 ) type Model struct { diff --git a/internal/tui/update.go b/internal/tui/update.go index 1ebbc2e..ec4f2de 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -19,7 +19,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tabDataMsg: return m.handleTabData(msg) case detailDataMsg: - if m.detailOpen && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID { + detailVisible := m.detailOpen || m.state == stateDetailFullscreen + if detailVisible && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID { return m, nil } m.detailChanges = msg.changes @@ -51,6 +52,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.state == stateLogs { return m.handleLogsFullscreen(msg) } + if m.state == stateDetailFullscreen { + return m.handleDetailFullscreen(msg) + } switch msg := msg.(type) { case tea.MouseMsg: @@ -203,7 +207,7 @@ func (m *Model) handleTick(t time.Time) (tea.Model, tea.Cmd) { // tab-data cadence, so a flap that happens while the panel is on screen shows // up without leaving and re-entering. Nil when no detail panel is open. func (m *Model) detailRefreshCmd() tea.Cmd { - if !m.detailOpen || m.cursor >= len(m.sites) { + if (!m.detailOpen && m.state != stateDetailFullscreen) || m.cursor >= len(m.sites) { return nil } return m.loadDetailCmd(m.sites[m.cursor].ID) @@ -278,6 +282,75 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) handleDetailFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc", "q": + m.state = stateDashboard + m.focusedPanel = panelMonitors + case "ctrl+c": + return m, tea.Quit + case "e": + return m.handleEditItem() + case "h": + if m.cursor < len(m.sites) { + site := m.sites[m.cursor] + m.historySiteName = site.Name + m.historySiteID = site.ID + m.historyChanges = nil + m.detailMode = detailHistory + m.detailScrollOffset = 0 + return m, m.loadHistoryCmd(site.ID) + } + case "s": + if m.cursor < len(m.sites) { + site := m.sites[m.cursor] + m.slaSiteName = site.Name + m.slaSiteID = site.ID + m.slaPeriodIdx = 2 + m.detailMode = detailSLA + m.detailScrollOffset = 0 + return m, m.loadSLACmd(site.ID, m.slaPeriodIdx) + } + case "1", "2", "3", "4": + if m.detailMode == detailSLA { + idx := int(msg.String()[0]-'0') - 1 + if idx >= 0 && idx < len(slaPeriods) { + m.slaPeriodIdx = idx + m.detailScrollOffset = 0 + return m, m.loadSLACmd(m.slaSiteID, idx) + } + } + case "up", "k": + m.detailScrollOffset-- + if m.detailScrollOffset < 0 { + m.detailScrollOffset = 0 + } + case "down", "j": + m.detailScrollOffset++ + case "pgup": + m.detailScrollOffset -= 10 + if m.detailScrollOffset < 0 { + m.detailScrollOffset = 0 + } + case "pgdown": + m.detailScrollOffset += 10 + } + case tea.MouseMsg: + switch msg.Button { + case tea.MouseButtonWheelUp: + m.detailScrollOffset -= 3 + if m.detailScrollOffset < 0 { + m.detailScrollOffset = 0 + } + case tea.MouseButtonWheelDown: + m.detailScrollOffset += 3 + } + } + return m, nil +} + func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if m.state != stateDashboard { return m, nil @@ -673,6 +746,13 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } if len(m.sites) > 0 { + narrow := m.termWidth < wideBreakpoint + if narrow { + m.detailMode = detailDefault + m.detailScrollOffset = 0 + m.state = stateDetailFullscreen + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } m.detailOpen = !m.detailOpen m.detailMode = detailDefault m.detailScrollOffset = 0 diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 9d8aae0..97ac363 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -300,6 +300,70 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) { } } +func TestEnterNarrowTerminal_OpensFullscreen(t *testing.T) { + ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}} + m := newTestModel(ms) + m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "UP"}}} + m.termWidth = 80 // narrow, below wideBreakpoint (120) + m.termHeight = 40 + m.focusedPanel = panelMonitors + + updated, cmd := (&m).handleDashboardKey(keyMsg("enter")) + got := updated.(*Model) + if got.state != stateDetailFullscreen { + t.Fatalf("expected stateDetailFullscreen, got %d", got.state) + } + if cmd == nil { + t.Fatal("expected a detail load Cmd") + } +} + +func TestEnterWideTerminal_TogglesSidebar(t *testing.T) { + ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}} + m := newTestModel(ms) + m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "UP"}}} + m.termWidth = 140 // wide, above wideBreakpoint (120) + m.termHeight = 40 + m.focusedPanel = panelMonitors + + updated, _ := (&m).handleDashboardKey(keyMsg("enter")) + got := updated.(*Model) + if got.state != stateDashboard { + t.Fatalf("expected stateDashboard, got %d", got.state) + } + if !got.detailOpen { + t.Fatal("expected detailOpen to be true") + } +} + +func TestDetailFullscreen_EscReturnsToDashboard(t *testing.T) { + m := newTestModel(&tuiMockStore{}) + m.state = stateDetailFullscreen + m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}}} + + updated, _ := (&m).handleDetailFullscreen(tea.KeyMsg{Type: tea.KeyEsc}) + got := updated.(*Model) + if got.state != stateDashboard { + t.Fatalf("expected stateDashboard after Esc, got %d", got.state) + } + if got.focusedPanel != panelMonitors { + t.Fatalf("expected panelMonitors focus, got %d", got.focusedPanel) + } +} + +func TestDetailRefreshCmd_FiresForFullscreen(t *testing.T) { + ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}} + m := newTestModel(ms) + m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}} + m.state = stateDetailFullscreen + m.detailOpen = false + + cmd := (&m).detailRefreshCmd() + if cmd == nil { + t.Fatal("detailRefreshCmd should fire for stateDetailFullscreen") + } +} + func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) { ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}} m := newTestModel(ms) diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 13269c1..f653ec0 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -92,6 +92,8 @@ func (m Model) View() string { return "" case stateLogs: return m.viewLogsFullscreen() + case stateDetailFullscreen: + return m.viewDetailFullscreen() case stateAlertDetail: return m.viewAlertDetailPanel() case stateSettings: diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index ebf6da3..2615812 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "sort" "strings" "time" @@ -22,20 +23,90 @@ func (m Model) viewDetailInline(width, height int) string { default: site := m.sites[m.cursor] hist, _ := m.engine.GetHistory(site.ID) - return m.viewDetailSidebar(site, hist, width, height) + return m.buildDetailContent(site, hist, width, false) } } -func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width, _ int) string { +func (m Model) viewDetailFullscreen() string { + if m.cursor >= len(m.sites) { + return "" + } + + totalW := m.termWidth - chromePadH + site := m.sites[m.cursor] + + var title string + switch m.detailMode { + case detailSLA: + title = "SLA · " + site.Name + case detailHistory: + title = "History · " + site.Name + default: + title = site.Name + } + + var breadcrumb string + if site.ParentID > 0 { + for _, s := range m.sites { + if s.ID == site.ParentID { + breadcrumb = m.st.subtleStyle.Render("Monitors > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name) + break + } + } + } + if breadcrumb == "" { + breadcrumb = m.st.subtleStyle.Render("Monitors > ") + m.st.titleStyle.Render(title) + } + + header := " " + breadcrumb + "\n" + m.divider() + + var content string + switch m.detailMode { + case detailSLA: + content = m.viewSLASidebar(totalW, 0) + case detailHistory: + content = m.viewHistorySidebar(totalW, 0) + default: + hist, _ := m.engine.GetHistory(site.ID) + content = m.buildDetailContent(site, hist, totalW, true) + } + + footer := m.divider() + "\n" + m.detailFooter(totalW) + + contentLines := strings.Split(content, "\n") + contentH := m.termHeight - 8 + if contentH < 5 { + contentH = 5 + } + + if m.detailScrollOffset > len(contentLines)-contentH { + m.detailScrollOffset = len(contentLines) - contentH + } + if m.detailScrollOffset < 0 { + m.detailScrollOffset = 0 + } + + end := m.detailScrollOffset + contentH + if end > len(contentLines) { + end = len(contentLines) + } + visible := strings.Join(contentLines[m.detailScrollOffset:end], "\n") + + return lipgloss.NewStyle().Padding(1, 2).Render( + header + "\n" + visible + "\n" + footer) +} + +func (m Model) buildDetailContent(site models.Site, hist monitor.SiteHistory, width int, fullscreen bool) string { dot := m.st.subtleStyle.Render(" · ") label := m.st.subtleStyle - var b strings.Builder innerW := width - 4 if innerW < 20 { innerW = 20 } - // Status + latency + last check + var b strings.Builder + + // Status + latency + last check + state since status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)) statusParts := []string{status} if site.Latency > 0 { @@ -44,6 +115,10 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid if !site.LastCheck.IsZero() { statusParts = append(statusParts, m.fmtTimeAgo(site.LastCheck)) } + if !site.StatusChangedAt.IsZero() { + dur := time.Since(site.StatusChangedAt) + statusParts = append(statusParts, label.Render("for")+" "+fmtDuration(dur)) + } b.WriteString(" " + strings.Join(statusParts, dot) + "\n") // Type-specific details @@ -52,7 +127,10 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid b.WriteString(" " + strings.Join(typeParts, dot) + "\n") } - // Uptime + retries + // Extended endpoint fields + m.writeEndpointFields(&b, site, label, innerW, fullscreen) + + // Uptime + retries + last success uptimeStr := m.fmtUptime(hist.Statuses) if m.isMonitorInMaintenance(site.ID) { uptimeStr = m.st.subtleStyle.Render("—") @@ -61,8 +139,21 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid if site.Type != "group" && site.MaxRetries > 0 { uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site)) } + if site.Type != "push" && !site.LastSuccessAt.IsZero() { + uptimeParts = append(uptimeParts, label.Render("Last OK")+" "+m.fmtTimeAgo(site.LastSuccessAt)) + } b.WriteString(" " + strings.Join(uptimeParts, dot) + "\n") + // Maintenance window name + if m.isMonitorInMaintenance(site.ID) { + for _, mw := range m.maintenanceWindows { + if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) { + b.WriteString(" " + label.Render("Maint") + " " + m.st.maintStyle.Render(mw.Title) + "\n") + break + } + } + } + // Error line if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" { @@ -73,8 +164,62 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n") } + // Connection chain + if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" { + chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https")) + if len(chain) > 0 { + b.WriteString("\n") + for _, step := range chain { + var icon string + switch step.Status { + case stepPassed: + icon = m.st.specialStyle.Render("✓") + case stepFailed: + icon = m.st.dangerStyle.Render("✗") + case stepSkipped: + icon = m.st.subtleStyle.Render("·") + } + line := fmt.Sprintf(" %s %-16s", icon, step.Name) + if step.Detail != "" { + switch step.Status { + case stepFailed: + line += " " + m.st.dangerStyle.Render(step.Detail) + case stepSkipped: + line += " " + m.st.subtleStyle.Render(step.Detail) + } + } + b.WriteString(line + "\n") + } + } + } + b.WriteString("\n") + // Probe results + probeResults := m.engine.GetProbeResults(site.ID) + if len(probeResults) > 0 { + nodeIDs := make([]string, 0, len(probeResults)) + for id := range probeResults { + nodeIDs = append(nodeIDs, id) + } + sort.Strings(nodeIDs) + for _, nodeID := range nodeIDs { + result := probeResults[nodeID] + probeStatus := m.st.specialStyle.Render("UP") + if !result.IsUp { + probeStatus = m.st.dangerStyle.Render("DN") + } + latency := time.Duration(result.LatencyNs).Milliseconds() + ago := time.Since(result.CheckedAt).Truncate(time.Second) + line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, probeStatus, latency, ago) + if !result.IsUp && result.ErrorReason != "" { + line += " " + m.st.dangerStyle.Render(result.ErrorReason) + } + b.WriteString(line + "\n") + } + b.WriteString("\n") + } + // Latency chart if len(hist.Latencies) > 0 { chart := m.latencyChart(hist.Latencies, hist.Statuses, innerW, 3) @@ -116,6 +261,15 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid } } + // Latency histogram + if site.Type != "push" && len(hist.Latencies) > 5 { + histContent := m.latencyHistogram(hist.Latencies, hist.Statuses, innerW) + if histContent != "" { + b.WriteString("\n") + b.WriteString(histContent) + } + } + b.WriteString("\n") // State changes @@ -150,6 +304,47 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) } +func (m Model) writeEndpointFields(b *strings.Builder, site models.Site, label lipgloss.Style, innerW int, fullscreen bool) { + dot := m.st.subtleStyle.Render(" · ") + var fields []string + + if site.Interval > 0 { + fields = append(fields, label.Render("Every")+" "+fmt.Sprintf("%ds", site.Interval)) + } + if site.Timeout > 0 { + fields = append(fields, label.Render("Timeout")+" "+fmt.Sprintf("%ds", site.Timeout)) + } + if site.Type == "http" && site.Method != "" && site.Method != "GET" { + fields = append(fields, label.Render("Method")+" "+site.Method) + } + if site.Type == "http" { + codes := site.AcceptedCodes + if codes == "" { + codes = "200-299" + } + fields = append(fields, label.Render("Codes")+" "+codes) + } + if site.Regions != "" { + fields = append(fields, label.Render("Regions")+" "+site.Regions) + } + + if len(fields) > 0 { + b.WriteString(" " + strings.Join(fields, dot) + "\n") + } + + if site.Description != "" { + maxDescW := innerW + if !fullscreen && maxDescW > 60 { + maxDescW = 60 + } + b.WriteString(" " + label.Render(limitStr(site.Description, maxDescW)) + "\n") + } + + if site.Type == "push" && site.Token != "" { + b.WriteString(" " + label.Render("Token") + " " + site.Token + "\n") + } +} + func (m Model) detailTypeLine(site models.Site) []string { label := m.st.subtleStyle var parts []string