diff --git a/Dockerfile b/Dockerfile index 40fdb00..9541d17 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # --- Stage 1: Builder --- -FROM golang:1.26.4-alpine3.23@sha256:f23e8b227fb4493eabe03bede4d5a32d04092da71962f1fb79b5f7d1e6c2a17f AS builder +FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS builder WORKDIR /app COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ diff --git a/go.mod b/go.mod index 5f8b4a9..46014ed 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module gitea.lerkolabs.com/lerkolabs/uptop -go 1.26.4 +go 1.26.5 require ( github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 diff --git a/internal/tui/data.go b/internal/tui/data.go index c9d4a8e..ef762d8 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -77,8 +77,6 @@ func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol ungrouped = append(ungrouped, s) } } - sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID }) - sortSlice := func(s []models.Site) { sort.Slice(s, func(i, j int) bool { return s[i].ID < s[j].ID }) sort.SliceStable(s, func(i, j int) bool { @@ -98,6 +96,7 @@ func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol }) } + sortSlice(groups) for pid := range children { c := children[pid] sortSlice(c) @@ -169,6 +168,118 @@ func (m *Model) clampCursor() { } } +func (m *Model) detailCmdIfNeeded() tea.Cmd { + if m.focusedPanel == panelMonitors && m.detailOpen && m.cursor < len(m.sites) { + m.detailMode = detailDefault + m.detailScrollOffset = 0 + return m.loadDetailCmd(m.sites[m.cursor].ID) + } + return nil +} + +func (m *Model) jumpToTop() { + switch m.focusedPanel { + case panelDetail: + m.detailScrollOffset = 0 + case panelMaint: + m.maintCursor = 0 + case panelLogs: + m.logScrollOffset = 0 + default: + m.cursor = 0 + m.tableOffset = 0 + m.syncSelectedID() + } +} + +func (m *Model) jumpToBottom() { + switch m.focusedPanel { + case panelDetail: + m.detailScrollOffset = 9999 + case panelMaint: + windows := m.activeMaintWindows() + if len(windows) > 0 { + m.maintCursor = len(windows) - 1 + } + case panelLogs: + total := m.filteredLogCount() + if total > 0 { + m.logScrollOffset = total - 1 + } + default: + max := m.currentListLen() - 1 + if max >= 0 { + m.cursor = max + if m.cursor >= m.tableOffset+m.maxTableRows { + m.tableOffset = m.cursor - m.maxTableRows + 1 + } + m.syncSelectedID() + } + } +} + +func (m *Model) halfPageUp() { + half := m.maxTableRows / 2 + if half < 1 { + half = 1 + } + switch m.focusedPanel { + case panelDetail: + m.detailScrollOffset -= half + if m.detailScrollOffset < 0 { + m.detailScrollOffset = 0 + } + case panelMaint: + m.maintCursor -= half + if m.maintCursor < 0 { + m.maintCursor = 0 + } + case panelLogs: + m.scrollLogs(-half) + default: + m.cursor -= half + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor < m.tableOffset { + m.tableOffset = m.cursor + } + m.syncSelectedID() + } +} + +func (m *Model) halfPageDown() { + half := m.maxTableRows / 2 + if half < 1 { + half = 1 + } + switch m.focusedPanel { + case panelDetail: + m.detailScrollOffset += half + case panelMaint: + windows := m.activeMaintWindows() + m.maintCursor += half + if len(windows) > 0 && m.maintCursor >= len(windows) { + m.maintCursor = len(windows) - 1 + } + case panelLogs: + m.scrollLogs(half) + default: + max := m.currentListLen() - 1 + m.cursor += half + if m.cursor > max { + m.cursor = max + } + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= m.tableOffset+m.maxTableRows { + m.tableOffset = m.cursor - m.maxTableRows + 1 + } + m.syncSelectedID() + } +} + // loadTabDataCmd returns a tea.Cmd that loads the DB-backed tab tables off the // UI goroutine. Each call bumps tabSeq and stamps the reply with it, so // handleTabData can drop out-of-order results from slower earlier loads. The diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c8a7203..aa97b5b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -218,6 +218,8 @@ type Model struct { filterMode bool filterText string + pendingG bool + // 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 diff --git a/internal/tui/update.go b/internal/tui/update.go index 16b464f..bef9808 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -252,6 +252,17 @@ func (m *Model) testAlertCmd(id int, name string) tea.Cmd { func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + if msg.String() == "g" { + if m.pendingG { + m.pendingG = false + m.logViewport.GotoTop() + return m, nil + } + m.pendingG = true + return m, nil + } + m.pendingG = false + switch msg.String() { case "esc", "q": m.state = stateDashboard @@ -261,6 +272,12 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { case "f": m.logFilterImportant = !m.logFilterImportant m.refreshLogContent() + case "G": + m.logViewport.GotoBottom() + case "ctrl+u": + m.logViewport.ScrollUp(m.logViewport.Height / 2) + case "ctrl+d": + m.logViewport.ScrollDown(m.logViewport.Height / 2) case "up", "k": m.logViewport.ScrollUp(1) case "down", "j": @@ -286,6 +303,17 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) handleDetailFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + if msg.String() == "g" { + if m.pendingG { + m.pendingG = false + m.detailScrollOffset = 0 + return m, nil + } + m.pendingG = true + return m, nil + } + m.pendingG = false + switch msg.String() { case "esc", "q": m.state = stateDashboard @@ -323,6 +351,15 @@ func (m *Model) handleDetailFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.loadSLACmd(m.slaSiteID, idx) } } + case "G": + m.detailScrollOffset = 9999 + case "ctrl+u": + m.detailScrollOffset -= 5 + if m.detailScrollOffset < 0 { + m.detailScrollOffset = 0 + } + case "ctrl+d": + m.detailScrollOffset += 5 case "up", "k": m.detailScrollOffset-- if m.detailScrollOffset < 0 { @@ -544,7 +581,52 @@ func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "g" { + if m.pendingG { + m.pendingG = false + m.settingsCursor = 0 + m.settingsOffset = 0 + return m, nil + } + m.pendingG = true + return m, nil + } + m.pendingG = false + switch msg.String() { + case "G": + max := m.settingsListLen() - 1 + if max >= 0 { + m.settingsCursor = max + if m.settingsCursor >= m.settingsOffset+m.maxTableRows { + m.settingsOffset = m.settingsCursor - m.maxTableRows + 1 + } + } + case "ctrl+u": + half := m.maxTableRows / 2 + if half < 1 { + half = 1 + } + m.settingsCursor -= half + if m.settingsCursor < 0 { + m.settingsCursor = 0 + } + if m.settingsCursor < m.settingsOffset { + m.settingsOffset = m.settingsCursor + } + case "ctrl+d": + half := m.maxTableRows / 2 + if half < 1 { + half = 1 + } + max := m.settingsListLen() - 1 + m.settingsCursor += half + if m.settingsCursor > max { + m.settingsCursor = max + } + if m.settingsCursor >= m.settingsOffset+m.maxTableRows { + m.settingsOffset = m.settingsCursor - m.maxTableRows + 1 + } case "esc", "S": m.state = stateDashboard case "ctrl+c": @@ -635,7 +717,27 @@ func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "g" { + if m.pendingG { + m.pendingG = false + m.jumpToTop() + return m, m.detailCmdIfNeeded() + } + m.pendingG = true + return m, nil + } + m.pendingG = false + switch msg.String() { + case "G": + m.jumpToBottom() + return m, m.detailCmdIfNeeded() + case "ctrl+u": + m.halfPageUp() + return m, m.detailCmdIfNeeded() + case "ctrl+d": + m.halfPageDown() + return m, m.detailCmdIfNeeded() case "q": return m, tea.Quit case "/":