diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7cb209e..dd97daf 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -179,7 +179,7 @@ type Model struct { lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle) tabSeq int // seq of the newest issued tab-data load - // detail-panel state-change history, loaded on enter so View does no DB IO + detailOpen bool detailChanges []models.StateChange detailChangesSiteID int diff --git a/internal/tui/update.go b/internal/tui/update.go index 5800bf0..a0b00cb 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -517,7 +517,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "tab": m.switchTab(m.currentTab + 1) - case "left", "h": + case "left": if m.currentTab == tabSettings { m.switchSettingsSection(m.settingsSection - 1) } @@ -532,6 +532,9 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.tableOffset = m.cursor } m.syncSelectedID() + if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } } case "down", "j": max := m.currentListLen() - 1 @@ -541,6 +544,9 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.tableOffset++ } m.syncSelectedID() + if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } } case "n": return m.handleNewItem() @@ -574,11 +580,35 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "i": if m.currentTab == tabMonitors && len(m.sites) > 0 { - m.state = stateDetail - return m, m.loadDetailCmd(m.sites[m.cursor].ID) + m.detailOpen = !m.detailOpen + if m.detailOpen { + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } } else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 { m.state = stateAlertDetail } + case "esc": + if m.currentTab == tabMonitors && m.detailOpen { + m.detailOpen = false + } + case "h": + if m.detailOpen && m.currentTab == tabMonitors && 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.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + return m, m.openSLAView(m.sites[m.cursor]) + } case "x": if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 { mw := m.maintenanceWindows[m.cursor] diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 9f67cd0..10a13fe 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -162,10 +162,22 @@ func (m Model) viewDashboard() string { left := lipgloss.NewStyle().Width(leftW).Render(monitors) sidebar := m.viewLogsSidebar(rightW) right := lipgloss.NewStyle().Width(rightW).Render(sidebar) - content = lipgloss.JoinHorizontal(lipgloss.Top, left, right) + top := lipgloss.JoinHorizontal(lipgloss.Top, left, right) + if m.detailOpen { + detail := m.viewDetailInline(availW) + content = top + "\n" + detail + } else { + content = top + } } else { m.contentWidth = m.termWidth - content = m.viewSitesTab() + monitors := m.viewSitesTab() + if m.detailOpen { + detail := m.viewDetailInline(m.termWidth - chromePadH) + content = monitors + "\n" + detail + } else { + content = monitors + } } case tabMaint: m.contentWidth = m.termWidth diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go new file mode 100644 index 0000000..c28ea29 --- /dev/null +++ b/internal/tui/view_detail_inline.go @@ -0,0 +1,123 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" + "github.com/charmbracelet/lipgloss" +) + +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) + + var b strings.Builder + + title := m.st.titleStyle.Render(site.Name) + b.WriteString(" " + title + "\n") + + divW := width - 4 + if divW < 20 { + divW = 20 + } + b.WriteString(" " + m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n") + + status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)) + latency := m.fmtLatency(site.Latency) + uptime := m.fmtUptime(hist.Statuses) + + line1Parts := []string{status} + if site.Latency > 0 { + line1Parts = append(line1Parts, latency) + } + line1Parts = append(line1Parts, fmt.Sprintf("Uptime %s", uptime)) + if !site.LastCheck.IsZero() { + line1Parts = append(line1Parts, fmt.Sprintf("Checked %s", m.fmtTimeAgo(site.LastCheck))) + } + b.WriteString(" " + strings.Join(line1Parts, m.st.subtleStyle.Render(" · ")) + "\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 + } + errMsg := limitStr(site.LastError, errW) + b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(errMsg) + "\n") + } + + var stateChanges []models.StateChange + if m.detailChangesSiteID == site.ID { + stateChanges = m.detailChanges + } + if len(stateChanges) > 0 { + var parts []string + limit := 3 + if len(stateChanges) < limit { + limit = len(stateChanges) + } + 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)) + } + parts = append(parts, entry) + } + b.WriteString(" " + strings.Join(parts, m.st.subtleStyle.Render(" · ")) + "\n") + } + + if len(hist.Latencies) > 0 { + sparkW := width - 30 + if sparkW < 10 { + sparkW = 10 + } + if sparkW > detailSparkWidth { + sparkW = detailSparkWidth + } + spark := m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, m.theme.Bg) + minMs := hist.Latencies[0].Milliseconds() + maxMs := hist.Latencies[0].Milliseconds() + var sumMs int64 + for _, l := range hist.Latencies { + ms := l.Milliseconds() + if ms < minMs { + minMs = ms + } + if ms > maxMs { + maxMs = ms + } + sumMs += ms + } + avgMs := sumMs / int64(len(hist.Latencies)) + stats := fmt.Sprintf("Min %s Avg %s Max %s", + m.fmtLatency(time.Duration(minMs)*time.Millisecond), + m.fmtLatency(time.Duration(avgMs)*time.Millisecond), + m.fmtLatency(time.Duration(maxMs)*time.Millisecond)) + b.WriteString(" " + spark + " " + stats + "\n") + } + + keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close") + b.WriteString(" " + keys + "\n") + + return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) +} + +func (m Model) fmtStatusWord(status string) string { + switch status { + case "DOWN": + return m.st.dangerStyle.Render("DOWN") + case "UP": + return m.st.specialStyle.Render("UP") + default: + return m.st.subtleStyle.Render(status) + } +}