diff --git a/internal/tui/histogram.go b/internal/tui/histogram.go deleted file mode 100644 index 3c547cf..0000000 --- a/internal/tui/histogram.go +++ /dev/null @@ -1,89 +0,0 @@ -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/tab_logs_sidebar.go b/internal/tui/tab_logs_sidebar.go index 05b4e8e..dcd5da5 100644 --- a/internal/tui/tab_logs_sidebar.go +++ b/internal/tui/tab_logs_sidebar.go @@ -40,13 +40,13 @@ func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string { return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg } -func (m Model) viewLogsSidebar(width, maxLines int) string { +func (m Model) viewLogsStrip(width, maxLines int) string { logs := m.engine.GetLogs() if len(logs) == 0 { return m.st.subtleStyle.Render(" No logs yet") } - sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width) + style := lipgloss.NewStyle().Width(width).MaxWidth(width) var all []string for _, entry := range logs { @@ -69,7 +69,7 @@ func (m Model) viewLogsSidebar(width, maxLines int) string { } visible := all[start:end] - return sidebarStyle.Render(strings.Join(visible, "\n")) + return style.Render(strings.Join(visible, "\n")) } func (m *Model) scrollLogs(delta int) { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 05b7a3c..77c2834 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -80,8 +80,6 @@ const ( chromeFooter = 2 // footer: "\n" prefix + text line chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines) chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable - - detailSparkWidth = 40 ) const ( @@ -97,6 +95,12 @@ const ( panelMaint = 3 ) +const ( + detailDefault = 0 + detailSLA = 1 + detailHistory = 2 +) + const ( sortStatus = 0 sortName = 1 @@ -107,19 +111,16 @@ const ( type sessionState int const ( - stateDashboard sessionState = iota - stateLogs - stateDetail - stateAlertDetail - stateFormSite - stateFormAlert - stateFormUser - stateConfirmDelete - stateFormMaint - stateHistory - stateSLA - stateSettings - stateMaintDetail + 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 ) type Model struct { @@ -138,6 +139,7 @@ type Model struct { termHeight int contentWidth int focusedPanel int + detailMode int logScrollOffset int editID int editToken string @@ -154,12 +156,10 @@ type Model struct { logTotal int logShown int - historyViewport viewport.Model historyChanges []models.StateChange historySiteName string historySiteID int - slaViewport viewport.Model slaReport monitor.SLAReport slaDailyBreakdown []monitor.DayReport slaSiteName string @@ -205,13 +205,10 @@ type Model struct { detailChanges []models.StateChange detailChangesSiteID int detailDailyDays []monitor.DayReport - detailViewport viewport.Model filterMode bool filterText string - sparkTooltipIdx int // clicked sparkline data index, -1 = none - // demoMode renders a stable status dot instead of the animated pulse so // screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1. demoMode bool @@ -238,24 +235,23 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor detailPref, _ := s.GetPreference(ctx, "detail_open") return Model{ - ctx: ctx, - state: stateDashboard, - logViewport: vpLogs, - maxTableRows: 5, - isAdmin: isAdmin, - store: s, - engine: eng, - zones: z, - pulseSpring: spring, - collapsed: collapsed, - theme: theme, - themeIndex: themeIdx, - st: newStyles(theme), - logsOpen: true, - detailOpen: detailPref == "true", - demoMode: os.Getenv("UPTOP_DEMO") == "1", - version: version, - sparkTooltipIdx: -1, + ctx: ctx, + state: stateDashboard, + logViewport: vpLogs, + maxTableRows: 5, + isAdmin: isAdmin, + store: s, + engine: eng, + zones: z, + pulseSpring: spring, + collapsed: collapsed, + theme: theme, + themeIndex: themeIdx, + st: newStyles(theme), + logsOpen: true, + detailOpen: detailPref == "true", + demoMode: os.Getenv("UPTOP_DEMO") == "1", + version: version, } } diff --git a/internal/tui/update.go b/internal/tui/update.go index 13874f4..5b233a7 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -6,7 +6,6 @@ import ( "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" ) @@ -20,9 +19,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tabDataMsg: return m.handleTabData(msg) case detailDataMsg: - // Drop replies for a site the user has already navigated away from, - // so a slow load can't clobber the panel currently on screen. - if m.state == stateDetail && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID { + if m.detailOpen && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID { return m, nil } m.detailChanges = msg.changes @@ -31,11 +28,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case historyDataMsg: if msg.siteID != m.historySiteID { - return m, nil // stale reply for a previously opened history + return m, nil } m.historyChanges = msg.changes - m.historyViewport.SetContent(m.buildHistoryContent()) - m.historyViewport.GotoTop() return m, nil case slaDataMsg: return m.handleSLAData(msg) @@ -155,15 +150,15 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -const detailInlineHeight = 12 +const logsStripHeight = 6 func (m *Model) recalcLayout() { chrome := chromeBase if m.filterMode || m.filterText != "" { chrome++ } - if m.detailOpen { - chrome += detailInlineHeight + if m.logsOpen { + chrome += logsStripHeight } m.maxTableRows = m.termHeight - chrome if m.maxTableRows < 1 { @@ -177,10 +172,6 @@ func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { m.recalcLayout() m.logViewport.Width = msg.Width - chromePadH m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeFooter + 2) - m.historyViewport.Width = msg.Width - chromePadH - m.historyViewport.Height = msg.Height - 10 - m.slaViewport.Width = msg.Width - chromePadH - m.slaViewport.Height = msg.Height - 16 if m.huhForm != nil { formHeight := msg.Height - 7 if formHeight < 5 { @@ -212,7 +203,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.state != stateDetail || m.cursor >= len(m.sites) { + if !m.detailOpen || m.cursor >= len(m.sites) { return nil } return m.loadDetailCmd(m.sites[m.cursor].ID) @@ -259,7 +250,7 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc", "q": m.state = stateDashboard - m.focusedPanel = panelLogs + m.focusedPanel = panelMonitors case "ctrl+c": return m, tea.Quit case "f": @@ -288,30 +279,6 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - if m.state == stateHistory { - switch msg.Button { - case tea.MouseButtonWheelUp: - m.historyViewport.ScrollUp(3) - case tea.MouseButtonWheelDown: - m.historyViewport.ScrollDown(3) - } - 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 == stateDetail { - if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { - return m.handleSparklineClick(msg) - } - return m, nil - } if m.state != stateDashboard { return m, nil } @@ -367,12 +334,6 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch m.state { - case stateDetail: - return m.handleDetailKey(msg) - case stateHistory: - return m.handleHistoryKey(msg) - case stateSLA: - return m.handleSLAKey(msg) case stateAlertDetail: return m.handleAlertDetailKey(msg) case stateSettings: @@ -417,115 +378,6 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "up", "k": - m.detailViewport.ScrollUp(1) - return m, nil - case "down", "j": - m.detailViewport.ScrollDown(1) - return m, nil - case "pgup": - m.detailViewport.ScrollUp(m.detailViewport.Height / 2) - return m, nil - case "pgdown": - m.detailViewport.ScrollDown(m.detailViewport.Height / 2) - return m, nil - case "esc": - if m.sparkTooltipIdx >= 0 { - m.sparkTooltipIdx = -1 - return m, nil - } - m.sparkTooltipIdx = -1 - m.state = stateDashboard - case "i": - m.sparkTooltipIdx = -1 - m.state = stateDashboard - 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.historyViewport = viewport.New( - m.termWidth-chromePadH, - m.termHeight-10, - ) - m.historyViewport.SetContent("\n Loading state history...") - m.state = stateHistory - return m, m.loadHistoryCmd(site.ID) - } - case "s": - if m.cursor < len(m.sites) { - return m, m.openSLAView(m.sites[m.cursor]) - } - case "q": - m.state = stateDashboard - } - return m, nil -} - -func (m *Model) handleSparklineClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - if m.cursor >= len(m.sites) { - return m, nil - } - site := m.sites[m.cursor] - hist, _ := m.engine.GetHistory(site.ID) - - if zi := m.zones.Get("spark-latency"); zi != nil && !zi.IsZero() && zi.InBounds(msg) { - x, _ := zi.Pos(msg) - m.sparkTooltipIdx = resolveSparklineIndex(x, detailSparkWidth, len(hist.Latencies)) - return m, nil - } - if zi := m.zones.Get("spark-heartbeat"); zi != nil && !zi.IsZero() && zi.InBounds(msg) { - x, _ := zi.Pos(msg) - m.sparkTooltipIdx = resolveSparklineIndex(x, detailSparkWidth, len(hist.Statuses)) - return m, nil - } - - m.sparkTooltipIdx = -1 - 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 - return m, m.loadSLACmd(m.slaSiteID, idx) - } - 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) tea.Cmd { - m.slaSiteName = site.Name - m.slaSiteID = site.ID - m.slaPeriodIdx = 2 // default 30d - m.slaViewport = viewport.New( - m.termWidth-chromePadH, - m.termHeight-16, - ) - m.slaViewport.SetContent("\n Loading SLA report...") - m.state = stateSLA - return m.loadSLACmd(site.ID, m.slaPeriodIdx) -} - // handleSLAData folds an async SLA load into the model. The SLA math itself is // pure CPU and cheap, so it runs here; only the state-change read happens in // the Cmd. Replies for a different site or period than currently selected are @@ -546,35 +398,6 @@ func (m *Model) handleSLAData(msg slaDataMsg) (tea.Model, tea.Cmd) { m.slaReport = monitor.ComputeSLA(msg.changes, currentStatus, period.duration) m.slaDailyBreakdown = monitor.ComputeDailyBreakdown(msg.changes, currentStatus, period.days, time.Now()) - - m.slaViewport = viewport.New( - m.termWidth-chromePadH, - m.termHeight-16, - ) - m.slaViewport.SetContent(m.buildSLADailyContent()) - m.slaViewport.GotoTop() - return m, nil -} - -func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "esc": - m.state = stateDetail - case "up", "k": - m.historyViewport.ScrollUp(1) - case "down", "j": - m.historyViewport.ScrollDown(1) - case "pgup": - m.historyViewport.HalfPageUp() - case "pgdown": - m.historyViewport.HalfPageDown() - case "home", "g": - m.historyViewport.GotoTop() - case "end", "G": - m.historyViewport.GotoBottom() - case "ctrl+c": - return m, tea.Quit - } return m, nil } @@ -756,7 +579,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.recalcLayout() } case "l": - if m.focusedPanel == panelLogs { + if m.logsOpen { m.logsOpen = false m.focusedPanel = panelMonitors } else { @@ -780,6 +603,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) } } @@ -800,6 +624,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) } } @@ -825,8 +650,23 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } if len(m.sites) > 0 { - m.state = stateDetail - return m, m.loadDetailCmd(m.sites[m.cursor].ID) + site := m.sites[m.cursor] + if site.Type == "group" { + m.collapsed[site.ID] = !m.collapsed[site.ID] + payload := collapsedJSON(m.collapsed) + st := m.store + ctx := m.ctx + m.refreshLive() + return m, writeCmd("Save collapsed groups", func() error { + return st.SetPreference(ctx, "collapsed_groups", payload) + }) + } + if !m.detailOpen { + m.detailOpen = true + m.detailMode = detailDefault + m.recalcLayout() + } + return m, m.loadDetailCmd(site.ID) } case "e": return m.handleEditItem() @@ -879,8 +719,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 @@ -894,17 +737,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/update_test.go b/internal/tui/update_test.go index 9234289..d98d564 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -118,11 +118,10 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) { m := newTestModel(ms) m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}} m.cursor = 0 - m.state = stateDetail + m.detailOpen = true m.termWidth = 120 m.termHeight = 40 - // Entering detail dispatches the load Cmd. cmd := m.loadDetailCmd(1) if cmd == nil { t.Fatal("loadDetailCmd returned nil") @@ -136,16 +135,14 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) { t.Fatalf("expected exactly 1 store hit from the load Cmd, got %d", ms.stateChangeCalls) } - // Apply the msg through Update (caches into the model). updated, _ := m.Update(dd) m = updated.(Model) if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 { t.Fatalf("detail changes not cached: id=%d n=%d", m.detailChangesSiteID, len(m.detailChanges)) } - // Render the detail panel several times — it must read the cache, not the DB. for i := 0; i < 3; i++ { - _ = m.viewDetailPanel() + _ = m.viewDetailInline(80) } if ms.stateChangeCalls != 1 { t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls) @@ -202,16 +199,16 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) { ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}} m := newTestModel(ms) m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}} - m.state = stateDetail + m.detailOpen = true m.termWidth, m.termHeight = 120, 40 - updated, cmd := (&m).handleDetailKey(keyMsg("h")) + updated, cmd := (&m).handleDashboardKey(keyMsg("h")) if ms.stateChangeCalls != 0 { t.Fatal("history keypress hit the store synchronously in Update") } got := updated.(*Model) - if got.state != stateHistory || got.historySiteID != 7 { - t.Fatalf("history view not opened: state=%v siteID=%d", got.state, got.historySiteID) + if got.detailMode != detailHistory || got.historySiteID != 7 { + t.Fatalf("history mode not set: mode=%v siteID=%d", got.detailMode, got.historySiteID) } if cmd == nil { t.Fatal("expected a history load Cmd") @@ -229,7 +226,6 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) { t.Fatal("history reply not folded into the model") } - // A reply for a previously opened site must not clobber the current one. m2.historySiteID = 9 stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil}) if m3 := stale.(Model); len(m3.historyChanges) != 1 { @@ -241,20 +237,15 @@ func TestSLAData_DropsStaleReply(t *testing.T) { m := newTestModel(&tuiMockStore{}) m.termWidth, m.termHeight = 120, 40 m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 3}, SiteState: models.SiteState{Status: "UP"}}} + m.detailOpen = true + m.slaSiteID = 3 + m.slaPeriodIdx = 2 - if cmd := (&m).openSLAView(m.sites[0]); cmd == nil { - t.Fatal("openSLAView should return a load Cmd") - } - - // Reply for a different period than currently selected → dropped. - // (slaDataMsg routes through a pointer-receiver handler, so Update - // returns *Model on this path.) updated, _ := m.Update(slaDataMsg{siteID: 3, periodIdx: 0}) if mm := updated.(*Model); mm.slaDailyBreakdown != nil { t.Error("stale SLA reply (old period) was applied") } - // Matching reply → report computed. updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx}) if mm := updated.(*Model); mm.slaDailyBreakdown == nil { t.Error("matching SLA reply was not applied") @@ -314,12 +305,12 @@ func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) { m := newTestModel(ms) m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}} - m.state = stateDashboard + m.detailOpen = false if (&m).detailRefreshCmd() != nil { t.Error("refresh Cmd issued outside the detail view") } - m.state = stateDetail + m.detailOpen = true cmd := (&m).detailRefreshCmd() if cmd == nil { t.Fatal("open detail panel should refresh on the tab-data cadence") diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 20a0d2b..cb0345e 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -90,14 +90,8 @@ func (m Model) View() string { return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer) } return "" - case stateDetail: - return m.zones.Scan(m.viewDetailPanel()) case stateLogs: return m.viewLogsFullscreen() - case stateHistory: - return m.viewHistoryPanel() - case stateSLA: - return m.viewSLAPanel() case stateAlertDetail: return m.viewAlertDetailPanel() case stateSettings: @@ -156,9 +150,9 @@ func (m Model) viewMonitorsLayout() string { wide := m.termWidth >= wideBreakpoint showMaint := m.maintOpen && wide - showLogs := m.logsOpen && wide + showDetail := m.detailOpen && wide - var maintW, logsW, monW int + var maintW, detailW, monW int if showMaint { maintW = maintSidebarW if maintW > availW/4 { @@ -166,9 +160,9 @@ func (m Model) viewMonitorsLayout() string { } } remaining := availW - maintW - if showLogs { - monW = remaining * 70 / 100 - logsW = remaining - monW + if showDetail { + monW = remaining * 45 / 100 + detailW = remaining - monW } else { monW = remaining } @@ -185,22 +179,28 @@ func (m Model) viewMonitorsLayout() string { topParts = append(topParts, maintPanel) } topParts = append(topParts, monPanel) - if showLogs { - logContent := m.viewLogsSidebar(logsW-2, m.maxTableRows) - logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, logsW, m.focusedPanel == panelLogs)) - topParts = append(topParts, logPanel) + if showDetail { + title := "" + if m.cursor < len(m.sites) { + 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(title, detail, detailW, m.focusedPanel == panelDetail)) + topParts = append(topParts, detailPanel) } top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...) - if m.detailOpen { - site := "" - if m.cursor < len(m.sites) { - site = m.sites[m.cursor].Name - } - detail := m.viewDetailInline(availW - 2) - detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail)) - return top + "\n" + detailPanel + if m.logsOpen { + logContent := m.viewLogsStrip(availW-2, logsStripHeight-2) + logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, availW, m.focusedPanel == panelLogs)) + return top + "\n" + logPanel } return top } @@ -288,8 +288,12 @@ func (m Model) renderFooter(_ dashboardStats) string { keys = "[n]New [x]End [d]Del [Esc]Back [S]Settings [T]Theme [q]Quit" } else if m.focusedPanel == panelLogs { keys = "[↑/↓]Scroll [Enter]Expand [l/Esc]Back [S]Settings [T]Theme [q]Quit" + } else if m.detailOpen && m.detailMode == detailSLA { + keys = "[1-4]Period [Esc]Back [l]Logs [S]Settings [T]Theme [q]Quit" + } else if m.detailOpen && m.detailMode == detailHistory { + keys = "[Esc]Back [l]Logs [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" + keys = "[i]Close [h]History [s]SLA [e]Edit [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit" } else { keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit" } diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go deleted file mode 100644 index 8ba944e..0000000 --- a/internal/tui/view_detail.go +++ /dev/null @@ -1,342 +0,0 @@ -package tui - -import ( - "fmt" - "sort" - "strconv" - "strings" - "time" - - "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" - "github.com/charmbracelet/lipgloss" -) - -func (m Model) viewDetailPanel() string { - if m.cursor >= len(m.sites) { - return "" - } - site := m.sites[m.cursor] - hist, _ := m.engine.GetHistory(site.ID) - - var b strings.Builder - totalW := m.termWidth - chromePadH - - 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(site.Name) - } - b.WriteString(breadcrumb + "\n") - b.WriteString(m.divider() + "\n") - - // Two-column layout for key info - colW := (totalW - 4) / 2 - if colW < 30 { - colW = 30 - } - - row := func(label, value string) string { - return fmt.Sprintf(" %-16s %s", m.st.subtleStyle.Render(label), value) - } - - divW := totalW - 4 - if divW < 20 { - divW = 20 - } - sectionDiv := m.st.subtleStyle.Render(strings.Repeat("─", divW)) - sectionHead := func(title string) string { - return m.st.titleStyle.Render(" "+title) + " " + m.st.subtleStyle.Render(strings.Repeat("─", divW-len(title)-3)) - } - - // Left column: endpoint details - var left []string - left = append(left, m.st.titleStyle.Render(" ENDPOINT")) - left = append(left, row("Type", site.Type)) - if site.URL != "" { - left = append(left, row("URL", limitStr(site.URL, colW-19))) - } - if site.Hostname != "" { - left = append(left, row("Host", site.Hostname)) - } - if site.Port > 0 { - left = append(left, row("Port", strconv.Itoa(site.Port))) - } - if site.Interval > 0 { - left = append(left, row("Interval", fmt.Sprintf("%ds", site.Interval))) - } - if site.MaxRetries > 0 { - left = append(left, row("Retries", m.fmtRetries(site))) - } - if site.Regions != "" { - left = append(left, row("Regions", site.Regions)) - } - if site.Description != "" { - left = append(left, row("Description", limitStr(site.Description, colW-19))) - } - - // Right column: status + timing + HTTP - var right []string - right = append(right, m.st.titleStyle.Render(" STATUS")) - right = append(right, row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))) - right = append(right, row("Latency", m.fmtLatency(site.Latency))) - right = append(right, row("Uptime", m.fmtUptime(hist.Statuses))) - if !site.StatusChangedAt.IsZero() { - dur := time.Since(site.StatusChangedAt) - right = append(right, row("State Since", fmtDuration(dur)+" ago")) - } - if !site.LastCheck.IsZero() { - right = append(right, row("Last Check", m.fmtTimeAgo(site.LastCheck))) - } - if !site.LastSuccessAt.IsZero() { - right = append(right, row("Last Success", m.fmtTimeAgo(site.LastSuccessAt))) - } - - if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" { - errW := colW - 19 - if errW < 20 { - errW = 20 - } - right = append(right, row("Error", m.st.dangerStyle.Render(limitStr(site.LastError, errW)))) - } - - if site.Type == "http" { - if site.StatusCode > 0 { - right = append(right, row("HTTP Code", strconv.Itoa(site.StatusCode))) - } - codes := site.AcceptedCodes - if codes == "" { - codes = "200-299" - } - right = append(right, row("Codes", codes)) - right = append(right, row("SSL", m.fmtSSL(site))) - if site.Method != "" && site.Method != "GET" { - right = append(right, row("Method", site.Method)) - } - } - - // Pad shorter column - for len(left) < len(right) { - left = append(left, "") - } - for len(right) < len(left) { - right = append(right, "") - } - - leftCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(left, "\n")) - rightCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(right, "\n")) - b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) + "\n") - b.WriteString("\n" + sectionDiv + "\n") - - // Connection chain (full width, only on errors) - 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") - } - } - } - - // Maintenance - 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) { - fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Maintenance"), m.st.maintStyle.Render(mw.Title)) - break - } - } - } - - // Push token - if site.Type == "push" && site.Token != "" { - fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Token"), site.Token) - } - - // 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) - b.WriteString("\n" + sectionHead("PROBE RESULTS") + "\n") - for _, nodeID := range nodeIDs { - result := probeResults[nodeID] - status := m.st.specialStyle.Render("UP") - if !result.IsUp { - status = 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, status, latency, ago) - if !result.IsUp && result.ErrorReason != "" { - line += " " + m.st.dangerStyle.Render(result.ErrorReason) - } - b.WriteString(line + "\n") - } - } - - // Bottom two-column: graphs left, state changes right - graphW := (totalW - 4) * 70 / 100 - changeW := totalW - 4 - graphW - if graphW < 30 { - graphW = 30 - } - if changeW < 20 { - changeW = 20 - } - bottomColW := graphW - - sectionLabel := func(title string) string { - return m.st.titleStyle.Render(" " + title) - } - - // Left: latency + histogram (skip for groups — they have no own checks) - var graphLines []string - if site.Type != "group" { - graphLines = append(graphLines, sectionLabel("LATENCY")) - if site.Type == "push" { - sparkW := bottomColW - 4 - if sparkW > detailSparkWidth { - sparkW = detailSparkWidth - } - graphLines = append(graphLines, " "+m.heartbeatSparkline(hist.Statuses, sparkW, nil)) - if len(hist.Statuses) > 0 { - up := 0 - for _, s := range hist.Statuses { - if s { - up++ - } - } - graphLines = append(graphLines, fmt.Sprintf(" %s %d/%d checks up", - m.st.subtleStyle.Render("Heartbeats"), up, len(hist.Statuses))) - } - } else { - sparkW := bottomColW - 4 - if sparkW > detailSparkWidth { - sparkW = detailSparkWidth - } - graphLines = append(graphLines, " "+m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, nil)) - var minL, maxL, total time.Duration - count := 0 - for i, l := range hist.Latencies { - if i < len(hist.Statuses) && !hist.Statuses[i] { - continue - } - if count == 0 { - minL, maxL = l, l - } else if l < minL { - minL = l - } else if l > maxL { - maxL = l - } - total += l - count++ - } - if count > 0 { - avg := total / time.Duration(count) - graphLines = append(graphLines, fmt.Sprintf(" %s %dms %s %dms %s %dms", - m.st.subtleStyle.Render("Min"), minL.Milliseconds(), - m.st.subtleStyle.Render("Avg"), avg.Milliseconds(), - m.st.subtleStyle.Render("Max"), maxL.Milliseconds())) - } - } - - if site.Type != "push" && len(hist.Latencies) > 5 { - graphLines = append(graphLines, "") - graphLines = append(graphLines, sectionLabel("DISTRIBUTION")) - graphLines = append(graphLines, m.latencyHistogram(hist.Latencies, hist.Statuses, bottomColW)) - } - } - - // Right: state changes - var changeLines []string - var stateChanges []models.StateChange - if m.detailChangesSiteID == site.ID { - stateChanges = m.detailChanges - } - changeLines = append(changeLines, sectionLabel("STATE CHANGES")) - if len(stateChanges) > 0 { - for i, sc := range stateChanges { - from := m.fmtStatusWord(string(sc.FromStatus)) - to := m.fmtStatusWord(string(sc.ToStatus)) - ago := fmtDuration(time.Since(sc.ChangedAt)) - line := fmt.Sprintf(" %s → %s %s ago", from, to, ago) - if sc.ToStatus == "UP" { - dur := computeOutageDuration(stateChanges, i) - if dur > 0 { - line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur)) - } - } - if sc.ErrorReason != "" { - line += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, changeW-30)) - } - changeLines = append(changeLines, line) - } - } else { - changeLines = append(changeLines, m.st.subtleStyle.Render(" No state changes")) - } - - // Pad and join - for len(graphLines) < len(changeLines) { - graphLines = append(graphLines, "") - } - for len(changeLines) < len(graphLines) { - changeLines = append(changeLines, "") - } - - if len(graphLines) > 0 { - graphCol := lipgloss.NewStyle().Width(graphW).Render(strings.Join(graphLines, "\n")) - changeCol := lipgloss.NewStyle().Width(changeW).Render(strings.Join(changeLines, "\n")) - b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, graphCol, changeCol) + "\n") - } else { - b.WriteString(strings.Join(changeLines, "\n") + "\n") - } - - b.WriteString("\n") - b.WriteString(m.divider() + "\n") - b.WriteString(m.st.subtleStyle.Render(" [e] Edit [h] History [s] SLA [click] Inspect [q/Esc] Back")) - - // Wrap in a viewport for scrolling - content := b.String() - contentH := m.termHeight - 4 - if contentH < 10 { - contentH = 10 - } - lines := strings.Split(content, "\n") - if len(lines) > contentH { - m.detailViewport.SetContent(content) - m.detailViewport.Width = totalW - m.detailViewport.Height = contentH - return lipgloss.NewStyle().Padding(1, 2).Render(m.detailViewport.View()) - } - - return lipgloss.NewStyle().Padding(1, 2).Render(content) -} diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index d67ba6f..dbb4127 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -10,127 +10,111 @@ import ( "github.com/charmbracelet/lipgloss" ) -const detailTwoColMinWidth = 80 - 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) - - if width < detailTwoColMinWidth { - return m.viewDetailSingleCol(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) } - return m.viewDetailTwoCol(site, hist, width) } -func (m Model) viewDetailTwoCol(site models.Site, hist monitor.SiteHistory, width int) string { - leftW := width * 55 / 100 - rightW := width - leftW - 3 // 3 for " │ " divider - - left := m.detailLeftCol(site, hist, leftW) - right := m.detailRightCol(site, hist, rightW) - - leftLines := strings.Split(left, "\n") - rightLines := strings.Split(right, "\n") - - lineCount := len(leftLines) - if len(rightLines) > lineCount { - lineCount = len(rightLines) - } - for len(leftLines) < lineCount { - leftLines = append(leftLines, "") - } - for len(rightLines) < lineCount { - rightLines = append(rightLines, "") - } - - divChar := m.st.subtleStyle.Render("│") - leftStyle := lipgloss.NewStyle().Width(leftW).MaxWidth(leftW) - rightStyle := lipgloss.NewStyle().Width(rightW).MaxWidth(rightW) - +func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width int) string { + dot := m.st.subtleStyle.Render(" · ") + label := m.st.subtleStyle var b strings.Builder - for i := range lineCount { - l := leftStyle.Render(leftLines[i]) - r := rightStyle.Render(rightLines[i]) - b.WriteString(l + " " + divChar + " " + r + "\n") + innerW := width - 4 + if innerW < 20 { + innerW = 20 } - b.WriteString(" " + m.detailKeys() + "\n") + // Status + latency + last check + status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)) + statusParts := []string{status} + if site.Latency > 0 { + statusParts = append(statusParts, m.fmtLatency(site.Latency)) + } + if !site.LastCheck.IsZero() { + statusParts = append(statusParts, m.fmtTimeAgo(site.LastCheck)) + } + b.WriteString(" " + strings.Join(statusParts, dot) + "\n") - return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) -} + // Type-specific details + typeParts := m.detailTypeLine(site) + if len(typeParts) > 0 { + b.WriteString(" " + strings.Join(typeParts, dot) + "\n") + } -func (m Model) detailLeftCol(site models.Site, hist monitor.SiteHistory, width int) string { - var b strings.Builder + // Uptime + retries + uptimeParts := []string{label.Render("Uptime") + " " + m.fmtUptime(hist.Statuses)} + if site.Type != "group" && site.MaxRetries > 0 { + uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site)) + } + b.WriteString(" " + strings.Join(uptimeParts, dot) + "\n") - if len(hist.Latencies) > 0 { - chartW := width - 2 - if chartW < 20 { - chartW = 20 + // Error line + if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || + site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" { + errW := innerW + if errW < 20 { + errW = 20 } - chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3) + b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n") + } + + b.WriteString("\n") + + // Latency chart + if len(hist.Latencies) > 0 { + chart := m.latencyChart(hist.Latencies, hist.Statuses, innerW, 3) if chart != "" { b.WriteString(chart + "\n") } } + // 30d uptime timeline if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID { - timelineW := width - 2 - if timelineW < 20 { - timelineW = 20 + b.WriteString(" " + label.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, innerW) + "\n") + } + + // Sparkline + min/avg/max + if site.Type != "push" && len(hist.Latencies) > 0 { + b.WriteString(" " + m.latencySparkline(hist.Latencies, hist.Statuses, innerW, nil) + "\n") + var minL, maxL, total time.Duration + count := 0 + for i, l := range hist.Latencies { + if i < len(hist.Statuses) && !hist.Statuses[i] { + continue + } + if count == 0 { + minL, maxL = l, l + } else if l < minL { + minL = l + } else if l > maxL { + maxL = l + } + total += l + count++ } - b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n") - } - - return strings.TrimRight(b.String(), "\n") -} - -func (m Model) detailRightCol(site models.Site, hist monitor.SiteHistory, width int) string { - dot := m.st.subtleStyle.Render(" · ") - label := m.st.subtleStyle - - var b strings.Builder - - // Line 1: status + latency + last check - status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)) - parts := []string{status} - if site.Latency > 0 { - parts = append(parts, m.fmtLatency(site.Latency)) - } - if !site.LastCheck.IsZero() { - parts = append(parts, m.fmtTimeAgo(site.LastCheck)) - } - b.WriteString(strings.Join(parts, dot) + "\n") - - // Line 2: type-specific details - typeParts := m.detailTypeLine(site) - if len(typeParts) > 0 { - b.WriteString(strings.Join(typeParts, dot) + "\n") - } - - // Line 3: uptime + retries - uptimeParts := []string{label.Render("Uptime") + " " + m.fmtUptime(hist.Statuses)} - if site.Type != "group" && site.MaxRetries > 0 { - uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site)) - } - b.WriteString(strings.Join(uptimeParts, dot) + "\n") - - // Error line (if down/broken) - if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || - site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" { - errW := width - 8 - if errW < 20 { - errW = 20 + if count > 0 { + avg := total / time.Duration(count) + fmt.Fprintf(&b, " %s %dms %s %dms %s %dms\n", + label.Render("Min"), minL.Milliseconds(), + label.Render("Avg"), avg.Milliseconds(), + label.Render("Max"), maxL.Milliseconds()) } - b.WriteString(label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n") } - // Blank line before state changes b.WriteString("\n") - // State changes (one per line, compact) + // State changes var stateChanges []models.StateChange if m.detailChangesSiteID == site.ID { stateChanges = m.detailChanges @@ -145,19 +129,23 @@ func (m Model) detailRightCol(site models.Site, hist monitor.SiteHistory, width arrow := m.st.subtleStyle.Render("→") from := m.fmtStatusWord(sc.FromStatus) to := m.fmtStatusWord(sc.ToStatus) - entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago") + entry := from + " " + arrow + " " + to + " " + label.Render(ago+" ago") if sc.ErrorReason != "" { - reasonW := width - 30 + reasonW := innerW - 25 if reasonW < 15 { reasonW = 15 } entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW)) } - b.WriteString(entry + "\n") + b.WriteString(" " + entry + "\n") } + } else { + b.WriteString(" " + label.Render("No state changes") + "\n") } - return strings.TrimRight(b.String(), "\n") + b.WriteString("\n " + m.detailKeys() + "\n") + + return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) } func (m Model) detailTypeLine(site models.Site) []string { @@ -224,81 +212,8 @@ func (m Model) detailTypeLine(site models.Site) []string { return parts } -// viewDetailSingleCol is the narrow-terminal fallback (original stacked layout). -func (m Model) viewDetailSingleCol(site models.Site, hist monitor.SiteHistory, width int) string { - var b strings.Builder - dot := m.st.subtleStyle.Render(" · ") - - status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)) - parts := []string{status} - if site.Latency > 0 { - parts = append(parts, m.fmtLatency(site.Latency)) - } - parts = append(parts, fmt.Sprintf("Uptime %s", m.fmtUptime(hist.Statuses))) - if !site.LastCheck.IsZero() { - parts = append(parts, m.fmtTimeAgo(site.LastCheck)) - } - b.WriteString(" " + strings.Join(parts, dot) + "\n") - - if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || - site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" { - errW := width - 12 - if errW < 20 { - errW = 20 - } - b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n") - } - - var stateChanges []models.StateChange - if m.detailChangesSiteID == site.ID { - stateChanges = m.detailChanges - } - if len(stateChanges) > 0 { - limit := 3 - if len(stateChanges) < limit { - limit = len(stateChanges) - } - var scParts []string - for _, sc := range stateChanges[:limit] { - ago := fmtDuration(time.Since(sc.ChangedAt)) - arrow := m.st.subtleStyle.Render("→") - from := m.fmtStatusWord(sc.FromStatus) - to := m.fmtStatusWord(sc.ToStatus) - entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago") - if sc.ErrorReason != "" { - entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30)) - } - scParts = append(scParts, entry) - } - b.WriteString(" " + strings.Join(scParts, dot) + "\n") - } - - if len(hist.Latencies) > 0 { - chartW := width - 4 - if chartW < 20 { - chartW = 20 - } - chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3) - if chart != "" { - b.WriteString(chart + "\n") - } - } - - if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID { - timelineW := width - 4 - if timelineW < 20 { - timelineW = 20 - } - b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n") - } - - b.WriteString(" " + m.detailKeys() + "\n") - - return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.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 { @@ -319,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()) +} diff --git a/internal/tui/view_history.go b/internal/tui/view_history.go index 58c7b19..92d21a2 100644 --- a/internal/tui/view_history.go +++ b/internal/tui/view_history.go @@ -1,12 +1,10 @@ package tui import ( - "fmt" "strings" "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" - "github.com/charmbracelet/lipgloss" ) type historyStats struct { @@ -105,87 +103,3 @@ func (m Model) stateChangeSparkline(changes []models.StateChange, width int) str } 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.subtleStyle.Render("History >") + " " + m.st.titleStyle.Render(m.historySiteName) - 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("[↑/↓] Scroll [q/Esc] Back")) - - return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) -} diff --git a/internal/tui/view_sla.go b/internal/tui/view_sla.go index 27767c9..4d34783 100644 --- a/internal/tui/view_sla.go +++ b/internal/tui/view_sla.go @@ -5,8 +5,6 @@ import ( "math" "strings" "time" - - "github.com/charmbracelet/lipgloss" ) var slaPeriods = []struct { @@ -21,91 +19,6 @@ var slaPeriods = []struct { {"90d", "4", 90 * 24 * time.Hour, 90}, } -func (m Model) viewSLAPanel() string { - var b strings.Builder - - header := " " + m.st.subtleStyle.Render("SLA >") + " " + m.st.titleStyle.Render(m.slaSiteName) - b.WriteString(header + "\n") - b.WriteString(m.divider() + "\n") - - period := slaPeriods[m.slaPeriodIdx] - b.WriteString(" " + m.st.subtleStyle.Render("Period: Last "+period.label) + "\n\n") - - r := m.slaReport - - barWidth := m.dividerWidth() - 30 - 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, " %-16s %s %s\n", m.st.subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar) - fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Downtime"), fmtDuration(r.Downtime)) - fmt.Fprintf(&b, " %-16s %d\n", m.st.subtleStyle.Render("Outages"), r.OutageCount) - - if r.OutageCount > 0 { - fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Longest"), fmtDuration(r.LongestOut)) - fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTTR"), fmtDuration(r.MTTR)) - fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTBF"), fmtDuration(r.MTBF)) - } - - b.WriteString("\n" + m.divider() + "\n") - - if len(m.slaDailyBreakdown) > 0 { - b.WriteString(m.slaViewport.View()) - } - - b.WriteString("\n" + m.divider() + "\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, m.st.titleStyle.Render(label)) - } else { - keys = append(keys, m.st.subtleStyle.Render(label)) - } - } - b.WriteString(" " + strings.Join(keys, " ")) - b.WriteString(" " + m.st.subtleStyle.Render("[↑/↓] Scroll [q/Esc] Back")) - - return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) -} - -func (m Model) buildSLADailyContent() string { - var b strings.Builder - - barWidth := m.dividerWidth() - 30 - if barWidth < 10 { - barWidth = 10 - } - - b.WriteString(" " + m.st.titleStyle.Render("DAILY BREAKDOWN") + "\n") - for _, day := range m.slaDailyBreakdown { - dateStr := day.Date.Format("Jan 02") - bar := m.uptimeBar(day.UptimePct, barWidth) - 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", m.st.subtleStyle.Render(dateStr), bar, color.Render(pctStr)) - } - - return b.String() -} - func (m Model) uptimeBar(pct float64, width int) string { filled := int(math.Round(pct / 100 * float64(width))) if filled > width {