diff --git a/internal/models/models.go b/internal/models/models.go index b8983a4..319cbc1 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -86,6 +86,11 @@ type ProbeNode struct { Version string } +type LogEntry struct { + Message string + CreatedAt time.Time +} + // AlertHealthRecord is the persisted send health of an alert channel. It lets the // "last sent" / health indicators survive restarts instead of resetting to "never". type AlertHealthRecord struct { diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 779667c..929f99e 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -40,7 +40,7 @@ type Engine struct { liveState map[int]models.Site logMu sync.RWMutex - logStore []string + logStore []models.LogEntry activeMu sync.RWMutex isActive bool @@ -152,11 +152,13 @@ func fmtDurationShort(d time.Duration) string { // appendLog adds a timestamped entry to the in-memory ring buffer and returns // it. It never touches the database, so it is safe to call from the db-write // drop/error path without recursing back through the write queue. -func (e *Engine) appendLog(msg string) string { - ts := time.Now().Format("15:04:05") - entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg)) +func (e *Engine) appendLog(msg string) models.LogEntry { + entry := models.LogEntry{ + Message: sanitizeLog(msg), + CreatedAt: time.Now(), + } e.logMu.Lock() - e.logStore = append([]string{entry}, e.logStore...) + e.logStore = append([]models.LogEntry{entry}, e.logStore...) if len(e.logStore) > maxLogEntries { e.logStore = e.logStore[:maxLogEntries] } @@ -166,7 +168,7 @@ func (e *Engine) appendLog(msg string) string { func (e *Engine) AddLog(msg string) { entry := e.appendLog(msg) - e.enqueueWrite(writeLog{message: entry}) + e.enqueueWrite(writeLog{message: entry.Message}) } // enqueueWrite hands a persistence task to the writer goroutine without @@ -246,16 +248,16 @@ func (e *Engine) Stop() { } func (e *Engine) InitLogs() { - logs, err := e.db.LoadLogs(context.Background(), maxLogEntries) + entries, err := e.db.LoadLogs(context.Background(), maxLogEntries) if err != nil { return } - if len(logs) == 0 { + if len(entries) == 0 { return } e.logMu.Lock() defer e.logMu.Unlock() - e.logStore = logs + e.logStore = entries } // InitAlertHealth restores persisted alert send health so the dashboard shows real @@ -278,10 +280,10 @@ func (e *Engine) InitAlertHealth() { } } -func (e *Engine) GetLogs() []string { +func (e *Engine) GetLogs() []models.LogEntry { e.logMu.RLock() defer e.logMu.RUnlock() - logs := make([]string, len(e.logStore)) + logs := make([]models.LogEntry, len(e.logStore)) copy(logs, e.logStore) return logs } diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index 45b8925..8ebd0c6 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -25,7 +25,7 @@ type mockStore struct { sites []models.SiteConfig alerts map[int]models.AlertConfig maintenance map[int]bool - logs []string + logs []models.LogEntry history map[int][]models.CheckRecord savedChecks []savedCheck savedLogs []string @@ -103,7 +103,7 @@ func (m *mockStore) SaveLog(_ context.Context, msg string) error { return nil } -func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) { +func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return m.logs, nil } @@ -330,7 +330,7 @@ func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) { logs := e.GetLogs() found := false for _, l := range logs { - if containsStr(l, "suppressed") { + if containsStr(l.Message, "suppressed") { found = true break } @@ -973,14 +973,17 @@ func TestAddLog_PrependAndCap(t *testing.T) { if len(logs) != 100 { t.Errorf("expected 100 logs, got %d", len(logs)) } - if !containsStr(logs[0], "log-104") { - t.Errorf("expected newest log first, got %s", logs[0]) + if !containsStr(logs[0].Message, "log-104") { + t.Errorf("expected newest log first, got %s", logs[0].Message) } } func TestInitLogs_LoadsFromDB(t *testing.T) { ms := newMockStore() - ms.logs = []string{"old-log-1", "old-log-2"} + ms.logs = []models.LogEntry{ + {Message: "old-log-1"}, + {Message: "old-log-2"}, + } e := newTestEngine(ms) e.InitLogs() diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 0440bee..b6a96cb 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strings" "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" @@ -554,21 +555,27 @@ func (s *SQLStore) PruneLogs(ctx context.Context) error { return err } -func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]string, error) { - rows, err := s.db.QueryContext(ctx, s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit) +func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) { + rows, err := s.db.QueryContext(ctx, s.q("SELECT message, created_at FROM logs ORDER BY created_at DESC, id DESC LIMIT ?"), limit) if err != nil { return nil, err } defer rows.Close() - var logs []string + var entries []models.LogEntry for rows.Next() { - var msg string - if err := rows.Scan(&msg); err != nil { - return logs, err + var e models.LogEntry + if err := rows.Scan(&e.Message, &e.CreatedAt); err != nil { + return entries, err } - logs = append(logs, msg) + // Strip legacy [HH:MM] or [HH:MM:SS] prefix from pre-refactor entries. + if len(e.Message) > 3 && e.Message[0] == '[' { + if idx := strings.Index(e.Message, "]"); idx > 0 && idx < 12 { + e.Message = strings.TrimSpace(e.Message[idx+1:]) + } + } + entries = append(entries, e) } - return logs, rows.Err() + return entries, rows.Err() } func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) { diff --git a/internal/store/sqlstore_test.go b/internal/store/sqlstore_test.go index 01dbffe..85467c3 100644 --- a/internal/store/sqlstore_test.go +++ b/internal/store/sqlstore_test.go @@ -407,7 +407,7 @@ func TestPruneLogs(t *testing.T) { // LoadLogs ordering ties when rows share a created_at second). present := make(map[string]bool, len(logs)) for _, l := range logs { - present[l] = true + present[l.Message] = true } if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] { t.Error("newest log was pruned") diff --git a/internal/store/store.go b/internal/store/store.go index 53033bb..861b3dc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -61,7 +61,7 @@ type Store interface { // Logs SaveLog(ctx context.Context, message string) error - LoadLogs(ctx context.Context, limit int) ([]string, error) + LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) PruneLogs(ctx context.Context) error // Maintenance Windows diff --git a/internal/store/storetest/mock.go b/internal/store/storetest/mock.go index 04b04bc..759fbd2 100644 --- a/internal/store/storetest/mock.go +++ b/internal/store/storetest/mock.go @@ -212,8 +212,8 @@ func (m *BaseMock) SaveLog(ctx context.Context, message string) error { return nil } -func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil } -func (m *BaseMock) PruneLogs(_ context.Context) error { return nil } +func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return nil, nil } +func (m *BaseMock) PruneLogs(_ context.Context) error { return nil } func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) { if m.GetActiveMaintenanceWindowsFunc != nil { diff --git a/internal/tui/panel.go b/internal/tui/panel.go new file mode 100644 index 0000000..586af54 --- /dev/null +++ b/internal/tui/panel.go @@ -0,0 +1,52 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func (m Model) titledPanel(title, content string, width int, focused bool) string { + borderColor := m.theme.Border + titleColor := m.theme.Muted + if focused { + borderColor = m.theme.Accent + titleColor = m.theme.Accent + } + + bc := lipgloss.NewStyle().Foreground(borderColor) + tc := lipgloss.NewStyle().Foreground(titleColor).Bold(true) + + innerW := width - 2 + if innerW < 10 { + innerW = 10 + } + + titleRendered := tc.Render(" " + title + " ") + titleLen := len([]rune(title)) + 2 + fillLen := innerW - titleLen - 1 + if fillLen < 0 { + fillLen = 0 + } + + top := bc.Render("╭─") + titleRendered + bc.Render(strings.Repeat("─", fillLen)+"╮") + + contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW) + inner := contentStyle.Render(content) + + var lines []string + lines = append(lines, top) + for _, line := range strings.Split(inner, "\n") { + lines = append(lines, bc.Render("│")+line+strings.Repeat(" ", max(0, innerW-lipgloss.Width(line)))+bc.Render("│")) + } + lines = append(lines, bc.Render("╰"+strings.Repeat("─", innerW)+"╯")) + + return strings.Join(lines, "\n") +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/tui/tab_logs.go b/internal/tui/tab_logs.go index 29463ea..4d1ed8e 100644 --- a/internal/tui/tab_logs.go +++ b/internal/tui/tab_logs.go @@ -3,6 +3,8 @@ package tui import ( "fmt" "strings" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" ) type logSeverity int @@ -15,8 +17,8 @@ const ( severitySystem ) -func classifyLog(line string) logSeverity { - lower := strings.ToLower(line) +func classifyLog(msg string) logSeverity { + lower := strings.ToLower(msg) switch { case strings.Contains(lower, "confirmed down"), strings.Contains(lower, "is down"), @@ -63,44 +65,29 @@ func (m Model) renderLogTag(sev logSeverity) string { } } -func (m Model) renderLogLine(line string) string { - sev := classifyLog(line) +func (m Model) renderLogLine(entry models.LogEntry) string { + sev := classifyLog(entry.Message) tag := m.renderLogTag(sev) - - ts := "" - msg := line - if len(line) > 10 && line[0] == '[' { - if idx := strings.Index(line, "]"); idx > 0 && idx < 12 { - ts = m.st.subtleStyle.Render(line[1:idx]) - msg = strings.TrimSpace(line[idx+1:]) - } - } - - if ts != "" { - return fmt.Sprintf(" %s %s %s", ts, tag, msg) - } - return fmt.Sprintf(" %s %s", tag, msg) + ts := m.st.subtleStyle.Render(entry.CreatedAt.Local().Format("01/02 15:04")) + return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message) } -// refreshLogContent rebuilds the log viewport from the full engine log list, -// filtering before windowing so the entry count and "(n hidden)" reflect all -// logs, not just the visible viewport slice. func (m *Model) refreshLogContent() { var rendered []string total := 0 shown := 0 - for _, line := range m.engine.GetLogs() { - if strings.TrimSpace(line) == "" { + for _, entry := range m.engine.GetLogs() { + if strings.TrimSpace(entry.Message) == "" { continue } total++ - sev := classifyLog(line) + sev := classifyLog(entry.Message) if m.logFilterImportant && !isImportantLog(sev) { continue } shown++ - rendered = append(rendered, m.renderLogLine(line)) + rendered = append(rendered, m.renderLogLine(entry)) } m.logTotal = total diff --git a/internal/tui/tab_logs_sidebar.go b/internal/tui/tab_logs_sidebar.go index e21c6cd..05b4e8e 100644 --- a/internal/tui/tab_logs_sidebar.go +++ b/internal/tui/tab_logs_sidebar.go @@ -3,11 +3,12 @@ package tui import ( "strings" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "github.com/charmbracelet/lipgloss" ) -func (m Model) renderCompactLogLine(line string, maxW int) string { - sev := classifyLog(line) +func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string { + sev := classifyLog(entry.Message) var tag string switch sev { @@ -23,36 +24,23 @@ func (m Model) renderCompactLogLine(line string, maxW int) string { tag = m.st.subtleStyle.Render("·") } - ts := "" - msg := line - if len(line) > 10 && line[0] == '[' { - if idx := strings.Index(line, "]"); idx > 0 && idx < 12 { - ts = line[1:idx] - msg = strings.TrimSpace(line[idx+1:]) - } - } + ts := entry.CreatedAt.Local().Format("01/02 15:04") + msg := entry.Message msg = strings.TrimPrefix(msg, "Monitor ") msg = strings.TrimPrefix(msg, "Push ") - // prefix: " HH:MM ● " = 9 visible chars, or " ● " = 3 without timestamp - prefixW := 3 - if ts != "" { - prefixW = len(ts) + 4 - } + prefixW := len(ts) + 4 msgW := maxW - prefixW if msgW < 5 { msgW = 5 } msg = limitStr(msg, msgW) - if ts != "" { - return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg - } - return " " + tag + " " + msg + return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg } -func (m Model) viewLogsSidebar(width int) string { +func (m Model) viewLogsSidebar(width, maxLines int) string { logs := m.engine.GetLogs() if len(logs) == 0 { return m.st.subtleStyle.Render(" No logs yet") @@ -60,16 +48,51 @@ func (m Model) viewLogsSidebar(width int) string { sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width) - var lines []string - for _, line := range logs { - if strings.TrimSpace(line) == "" { + var all []string + for _, entry := range logs { + if strings.TrimSpace(entry.Message) == "" { continue } - if m.logFilterImportant && !isImportantLog(classifyLog(line)) { + if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) { continue } - lines = append(lines, m.renderCompactLogLine(line, width)) + all = append(all, m.renderCompactLogLine(entry, width)) } - return "\n" + sidebarStyle.Render(strings.Join(lines, "\n")) + start := m.logScrollOffset + if start > len(all) { + start = len(all) + } + end := start + maxLines + if end > len(all) { + end = len(all) + } + visible := all[start:end] + + return sidebarStyle.Render(strings.Join(visible, "\n")) +} + +func (m *Model) scrollLogs(delta int) { + logs := m.engine.GetLogs() + total := 0 + for _, entry := range logs { + if strings.TrimSpace(entry.Message) == "" { + continue + } + if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) { + continue + } + total++ + } + + m.logScrollOffset += delta + if m.logScrollOffset < 0 { + m.logScrollOffset = 0 + } + if m.logScrollOffset > total-1 { + m.logScrollOffset = total - 1 + } + if m.logScrollOffset < 0 { + m.logScrollOffset = 0 + } } diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index e2dd5d8..0fd36ee 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -52,8 +52,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en } t := table.New(). - Border(lipgloss.RoundedBorder()). - BorderStyle(m.st.tableBorderStyle). + Border(lipgloss.HiddenBorder()). Width(tableWidth). Headers(headers...). Rows(rows...). @@ -94,5 +93,5 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en return base }) - return "\n" + t.Render() + return t.Render() } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7cb209e..6f39829 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -96,6 +96,12 @@ const ( sectionUsers = 2 ) +const ( + panelMonitors = 0 + panelLogs = 1 + panelDetail = 2 +) + type sessionState int const ( @@ -124,6 +130,8 @@ type Model struct { termWidth int termHeight int contentWidth int + focusedPanel int + logScrollOffset int editID int editToken string @@ -179,7 +187,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 @@ -211,6 +219,8 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri } } + detailPref, _ := s.GetPreference(context.Background(), "detail_open") + return Model{ state: stateDashboard, logViewport: vpLogs, @@ -224,6 +234,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri theme: theme, themeIndex: themeIdx, st: newStyles(theme), + detailOpen: detailPref == "true", demoMode: os.Getenv("UPTOP_DEMO") == "1", version: version, sparkTooltipIdx: -1, diff --git a/internal/tui/update.go b/internal/tui/update.go index 5800bf0..1004871 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -142,11 +142,16 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +const detailInlineHeight = 8 + func (m *Model) recalcLayout() { chrome := chromeBase if m.filterMode || m.filterText != "" { chrome++ } + if m.detailOpen && m.currentTab == tabMonitors { + chrome += detailInlineHeight + } m.maxTableRows = m.termHeight - chrome if m.maxTableRows < 1 { m.maxTableRows = 1 @@ -269,6 +274,15 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } + if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { + if msg.Button == tea.MouseButtonWheelUp { + m.scrollLogs(-3) + } else { + m.scrollLogs(3) + } + return m, nil + } + listLen := m.currentListLen() if msg.Button == tea.MouseButtonWheelUp { if m.cursor > 0 { @@ -286,6 +300,9 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } } m.syncSelectedID() + if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } return m, nil } @@ -517,23 +534,45 @@ 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) } - case "right", "l": + case "right": if m.currentTab == tabSettings { m.switchSettingsSection(m.settingsSection + 1) } + case "l": + switch m.currentTab { + case tabSettings: + m.switchSettingsSection(m.settingsSection + 1) + case tabMonitors: + if m.focusedPanel == panelLogs { + m.focusedPanel = panelMonitors + } else { + m.focusedPanel = panelLogs + } + } case "up", "k": + if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { + m.scrollLogs(-1) + return m, nil + } if m.cursor > 0 { m.cursor-- if m.cursor < m.tableOffset { 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": + if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { + m.scrollLogs(1) + return m, nil + } max := m.currentListLen() - 1 if m.cursor < max { m.cursor++ @@ -541,10 +580,19 @@ 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() - case "e", "enter": + case "enter": + if m.currentTab == tabMonitors && len(m.sites) > 0 { + m.state = stateDetail + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } + return m.handleEditItem() + case "e": return m.handleEditItem() case "t": if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 { @@ -574,11 +622,59 @@ 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 + m.recalcLayout() + st := m.store + open := m.detailOpen + var cmd tea.Cmd + if m.detailOpen { + cmd = m.loadDetailCmd(m.sites[m.cursor].ID) + } + saveCmd := writeCmd("Save detail preference", func() error { + v := "false" + if open { + v = "true" + } + return st.SetPreference(context.Background(), "detail_open", v) + }) + if cmd != nil { + return m, tea.Batch(cmd, saveCmd) + } + return m, saveCmd } else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 { m.state = stateAlertDetail } + case "esc": + if m.currentTab == tabMonitors { + if m.focusedPanel != panelMonitors { + m.focusedPanel = panelMonitors + } else if m.detailOpen { + m.detailOpen = false + m.recalcLayout() + st := m.store + return m, writeCmd("Save detail preference", func() error { + return st.SetPreference(context.Background(), "detail_open", "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] @@ -707,6 +803,18 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } } + if m.currentTab == tabMonitors { + if m.zones.Get("panel-monitors").InBounds(msg) { + m.focusedPanel = panelMonitors + } else if m.zones.Get("panel-logs").InBounds(msg) { + m.focusedPanel = panelLogs + return m, nil + } else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) { + m.focusedPanel = panelDetail + return m, nil + } + } + prefix, listLen := m.currentZonePrefix() end := m.tableOffset + m.maxTableRows if end > listLen { @@ -716,6 +824,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) { m.cursor = i m.syncSelectedID() + if m.detailOpen && m.currentTab == tabMonitors { + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } return m, nil } } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 1a44013..11b98f6 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -300,7 +300,7 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) { mm := updated.(Model) found := false for _, line := range mm.engine.GetLogs() { - if strings.Contains(line, "Delete site failed: boom") { + if strings.Contains(line.Message, "Delete site failed: boom") { found = true } } diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 9f67cd0..d218b73 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -157,15 +157,39 @@ func (m Model) viewDashboard() string { availW := m.termWidth - chromePadH leftW := availW * 70 / 100 rightW := availW - leftW - m.contentWidth = leftW + m.contentWidth = leftW - 2 monitors := m.viewSitesTab() - 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) + monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors)) + sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows) + logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs)) + top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel) + 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)) + content = top + "\n" + detailPanel + } else { + content = top + } } else { - m.contentWidth = m.termWidth - content = m.viewSitesTab() + m.contentWidth = m.termWidth - 2 + monitors := m.viewSitesTab() + availW := m.termWidth - chromePadH + monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors)) + 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)) + content = monPanel + "\n" + detailPanel + } else { + content = monPanel + } } case tabMaint: m.contentWidth = m.termWidth @@ -279,7 +303,13 @@ func (m Model) renderFooter(stats dashboardStats) string { var keys string switch m.currentTab { case tabMonitors: - keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Space]Collapse [T]Theme [Tab]Switch [q]Quit" + if m.focusedPanel == panelLogs { + keys = "[↑/↓]Scroll [l/Esc]Back [T]Theme [q]Quit" + } else if m.detailOpen { + keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit" + } else { + keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit" + } case tabMaint: keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" case tabSettings: diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go new file mode 100644 index 0000000..da962c5 --- /dev/null +++ b/internal/tui/view_detail_inline.go @@ -0,0 +1,114 @@ +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 + + 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) + } +}