diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d54d23e..896fefa 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -95,6 +95,14 @@ const ( panelMaint = 3 ) +type bottomPanel int + +const ( + bottomNone bottomPanel = iota + bottomLogs + bottomMaint +) + const ( detailDefault = 0 detailSLA = 1 @@ -198,11 +206,9 @@ type Model struct { lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle) tabSeq int // seq of the newest issued tab-data load - logsOpen bool + bottomPanel bottomPanel detailOpen bool - maintOpen bool maintCursor int - maintOffset int detailChanges []models.StateChange detailChangesSiteID int detailDailyDays []monitor.DayReport @@ -249,7 +255,7 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor theme: theme, themeIndex: themeIdx, st: newStyles(theme), - logsOpen: true, + bottomPanel: bottomLogs, detailOpen: detailPref == "true", demoMode: os.Getenv("UPTOP_DEMO") == "1", version: version, diff --git a/internal/tui/update.go b/internal/tui/update.go index 97e9183..1ebbc2e 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -157,7 +157,7 @@ func (m *Model) recalcLayout() { if m.filterMode || m.filterText != "" { chrome++ } - if m.logsOpen { + if m.bottomPanel != bottomNone { chrome += logsStripHeight } m.maxTableRows = m.termHeight - chrome @@ -580,22 +580,20 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.sortAsc = !m.sortAsc m.refreshLive() case "m": - if m.termWidth >= wideBreakpoint { - if m.focusedPanel == panelMaint { - m.maintOpen = false - m.focusedPanel = panelMonitors - } else { - m.maintOpen = true - m.focusedPanel = panelMaint - } - m.recalcLayout() - } - case "l": - if m.logsOpen { - m.logsOpen = false + if m.bottomPanel == bottomMaint { + m.bottomPanel = bottomNone m.focusedPanel = panelMonitors } else { - m.logsOpen = true + m.bottomPanel = bottomMaint + m.focusedPanel = panelMaint + } + m.recalcLayout() + case "l": + if m.bottomPanel == bottomLogs { + m.bottomPanel = bottomNone + m.focusedPanel = panelMonitors + } else { + m.bottomPanel = bottomLogs m.focusedPanel = panelLogs } m.recalcLayout() @@ -868,11 +866,28 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } } - if m.maintOpen && m.zones.Get("panel-maint").InBounds(msg) { + if m.bottomPanel == bottomMaint && m.zones.Get("panel-maint").InBounds(msg) { m.focusedPanel = panelMaint + _, relY := m.zones.Get("panel-maint").Pos(msg) + row := relY - 2 + windows := m.activeMaintWindows() + if row >= 0 && row < len(windows) { + m.maintCursor = row + } return m, nil } else if m.zones.Get("panel-monitors").InBounds(msg) { m.focusedPanel = panelMonitors + _, relY := m.zones.Get("panel-monitors").Pos(msg) + row := relY - 4 + m.tableOffset + if row >= 0 && row < len(m.sites) { + m.cursor = row + m.syncSelectedID() + if m.detailOpen { + m.detailScrollOffset = 0 + return m, m.loadDetailCmd(m.sites[m.cursor].ID) + } + } + return m, nil } else if m.zones.Get("panel-logs").InBounds(msg) { m.focusedPanel = panelLogs return m, nil @@ -881,23 +896,6 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } - end := m.tableOffset + m.maxTableRows - if end > len(m.sites) { - end = len(m.sites) - } - for i := m.tableOffset; i < end; i++ { - if m.zones.Get(fmt.Sprintf("site-%d", i)).InBounds(msg) { - m.cursor = i - m.syncSelectedID() - m.focusedPanel = panelMonitors - if m.detailOpen { - m.detailScrollOffset = 0 - return m, m.loadDetailCmd(m.sites[m.cursor].ID) - } - return m, nil - } - } - return m, nil } diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 9b33d5e..0dbeae1 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -143,28 +143,18 @@ func (m Model) computeStats() dashboardStats { return s } -const maintSidebarW = 22 - func (m Model) viewMonitorsLayout() string { availW := m.termWidth - chromePadH wide := m.termWidth >= wideBreakpoint - showMaint := m.maintOpen && wide showDetail := m.detailOpen && wide - var maintW, detailW, monW int - if showMaint { - maintW = maintSidebarW - if maintW > availW/4 { - maintW = availW / 4 - } - } - remaining := availW - maintW + var detailW, monW int if showDetail { - monW = remaining * 60 / 100 - detailW = remaining - monW + monW = availW * 60 / 100 + detailW = availW - monW } else { - monW = remaining + monW = availW } m.contentWidth = monW - 2 @@ -173,11 +163,6 @@ func (m Model) viewMonitorsLayout() string { monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, monW, m.focusedPanel == panelMonitors)) var topParts []string - if showMaint { - sidebar := m.viewMaintSidebar(maintW-2, m.maxTableRows) - maintPanel := m.zones.Mark("panel-maint", m.titledPanel("Maint", sidebar, maintW, m.focusedPanel == panelMaint)) - topParts = append(topParts, maintPanel) - } topParts = append(topParts, monPanel) if showDetail { title := "" @@ -199,10 +184,15 @@ func (m Model) viewMonitorsLayout() string { top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...) - if m.logsOpen { + switch m.bottomPanel { + case bottomLogs: 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 + case bottomMaint: + maintContent := m.viewMaintStrip(availW-2, logsStripHeight-2) + maintPanel := m.zones.Mark("panel-maint", m.titledPanel("Maint", maintContent, availW, m.focusedPanel == panelMaint)) + return top + "\n" + maintPanel } return top } @@ -296,7 +286,7 @@ func (m Model) renderFooter(_ dashboardStats) string { var parts []string if m.focusedPanel == panelMaint { - parts = []string{m.hotkey("n", "New"), m.hotkey("x", "End"), m.hotkey("d", "Del"), m.hotkey("Esc", "Back")} + parts = []string{m.hotkey("n", "New"), m.hotkey("Enter", "Detail"), m.hotkey("x", "End"), m.hotkey("d", "Del"), m.hotkey("m/Esc", "Back")} } else if m.focusedPanel == panelLogs { parts = []string{m.hotkey("↑/↓", "Scroll"), m.hotkey("Enter", "Expand"), m.hotkey("l/Esc", "Back")} } else if m.detailOpen && m.detailMode == detailSLA { @@ -304,7 +294,7 @@ func (m Model) renderFooter(_ dashboardStats) string { } else if m.detailOpen && m.detailMode == detailHistory { parts = []string{m.hotkey("Esc", "Back")} } else if m.detailOpen { - parts = []string{m.hotkey("Enter", "Close"), m.hotkey("h", "History"), m.hotkey("s", "SLA"), m.hotkey("e", "Edit"), m.hotkey("m", "Maint")} + parts = []string{m.hotkey("Enter", "Close"), m.hotkey("h", "History"), m.hotkey("s", "SLA"), m.hotkey("e", "Edit")} } else { parts = []string{m.hotkey("/", "Filter"), m.hotkey("Enter", "Detail")} if m.cursor < len(m.sites) && m.sites[m.cursor].Type == "group" { @@ -314,9 +304,9 @@ func (m Model) renderFooter(_ dashboardStats) string { parts = append(parts, m.hotkey("Space", "Collapse")) } } - parts = append(parts, m.hotkey("n", "New"), m.hotkey("e", "Edit"), m.hotkey("d", "Del"), m.hotkey("m", "Maint")) + parts = append(parts, m.hotkey("n", "New"), m.hotkey("e", "Edit"), m.hotkey("d", "Del")) } - parts = append(parts, m.hotkey("l", "Logs"), m.hotkey("S", "Settings"), m.hotkey("T", "Theme"), m.hotkey("q", "Quit")) + parts = append(parts, m.hotkey("m", "Maint"), m.hotkey("l", "Logs"), m.hotkey("S", "Settings"), m.hotkey("T", "Theme"), m.hotkey("q", "Quit")) line := strings.Join(parts, dot) if m.filterText != "" { diff --git a/internal/tui/view_maint_sidebar.go b/internal/tui/view_maint_sidebar.go index 45c191f..789b05a 100644 --- a/internal/tui/view_maint_sidebar.go +++ b/internal/tui/view_maint_sidebar.go @@ -7,6 +7,7 @@ import ( "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" ) func (m Model) viewMaintDetailPanel() string { @@ -85,6 +86,15 @@ func (m Model) viewMaintDetailPanel() string { return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) } +func (m Model) monitorNameByID(id int) string { + for _, s := range m.engine.GetAllSites() { + if s.ID == id { + return s.Name + } + } + return fmt.Sprintf("#%d", id) +} + func (m Model) activeMaintWindows() []models.MaintenanceWindow { now := time.Now() var out []models.MaintenanceWindow @@ -97,34 +107,26 @@ func (m Model) activeMaintWindows() []models.MaintenanceWindow { return out } -func (m Model) viewMaintSidebar(width, maxLines int) string { +func (m Model) viewMaintStrip(width, maxLines int) string { windows := m.activeMaintWindows() if len(windows) == 0 { - return m.st.subtleStyle.Render(" No active maintenance") + return m.st.subtleStyle.Render(" No active maintenance") } - contentW := width - 2 - if contentW < 10 { - contentW = 10 - } - - start := m.maintOffset - if start > len(windows) { - start = len(windows) - } - linesUsed := 0 - end := start - for end < len(windows) && linesUsed+2 <= maxLines { - linesUsed += 2 - end++ - } - - var lines []string now := time.Now() - for i := start; i < end; i++ { - mw := windows[i] - selected := m.focusedPanel == panelMaint && i == m.maintCursor + end := maxLines + if end > len(windows) { + end = len(windows) + } + selectedVisual := -1 + if m.focusedPanel == panelMaint { + selectedVisual = m.maintCursor + } + + var rows [][]string + for i := 0; i < end; i++ { + mw := windows[i] isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) var icon string @@ -134,37 +136,51 @@ func (m Model) viewMaintSidebar(width, maxLines int) string { icon = m.st.warnStyle.Render("○") } - title := limitStr(mw.Title, contentW-3) - titleLine := " " + icon + " " + title + monName := "All Monitors" + if mw.MonitorID > 0 { + monName = m.monitorNameByID(mw.MonitorID) + } - var detail string + var status string if isActive { if mw.EndTime.IsZero() { - detail = m.st.subtleStyle.Render("active · indefinite") + status = "indefinite" } else { - remaining := time.Until(mw.EndTime) - detail = m.st.subtleStyle.Render("active · " + fmtDuration(remaining)) + status = fmtDuration(time.Until(mw.EndTime)) + " left" } } else { - detail = m.st.subtleStyle.Render(mw.StartTime.Format("Jan 02") + " · " + fmtDuration(mw.EndTime.Sub(mw.StartTime))) - } - detailLine := " " + limitStr(detail, contentW-3) - - if selected { - sel := m.st.tableSelectedStyle - titleLine = sel.Render(lipgloss.NewStyle().Width(contentW).Render(titleLine)) - detailLine = sel.Render(lipgloss.NewStyle().Width(contentW).Render(detailLine)) + status = "starts " + mw.StartTime.Format("Jan 02 15:04") } - lines = append(lines, titleLine, detailLine) + rows = append(rows, []string{icon, mw.Title, monName, status}) } - if len(windows) > end-start { - more := fmt.Sprintf(" %d more", len(windows)-(end-start)) - lines = append(lines, m.st.subtleStyle.Render(more)) - } + colWidths := []int{3, 0, 0, 0} + remaining := width - colWidths[0] - 6 + colWidths[1] = remaining * 40 / 100 + colWidths[2] = remaining * 30 / 100 + colWidths[3] = remaining - colWidths[1] - colWidths[2] - return strings.Join(lines, "\n") + t := table.New(). + Border(lipgloss.HiddenBorder()). + Width(width). + Rows(rows...). + StyleFunc(func(row, col int) lipgloss.Style { + isSelected := row == selectedVisual + base := m.st.tableCellStyle + if row%2 == 1 { + base = m.st.tableZebraStyle + } + if isSelected { + base = m.st.tableSelectedStyle + } + if col < len(colWidths) && colWidths[col] > 0 { + base = base.Width(colWidths[col]).MaxWidth(colWidths[col]) + } + return base + }) + + return t.Render() } func (m *Model) scrollMaintCursor(delta int) { @@ -180,14 +196,4 @@ func (m *Model) scrollMaintCursor(delta int) { if m.maintCursor >= total { m.maintCursor = total - 1 } - if m.maintCursor < m.maintOffset { - m.maintOffset = m.maintCursor - } - visible := m.maxTableRows / 2 - if visible < 1 { - visible = 1 - } - if m.maintCursor >= m.maintOffset+visible { - m.maintOffset = m.maintCursor - visible + 1 - } }