refactor(tui): move maintenance from sidebar to bottom strip #167

Merged
lerko merged 3 commits from refactor/maint-bottom-strip into main 2026-07-01 03:20:27 +00:00
4 changed files with 113 additions and 113 deletions
+10 -4
View File
@@ -95,6 +95,14 @@ const (
panelMaint = 3 panelMaint = 3
) )
type bottomPanel int
const (
bottomNone bottomPanel = iota
bottomLogs
bottomMaint
)
const ( const (
detailDefault = 0 detailDefault = 0
detailSLA = 1 detailSLA = 1
@@ -198,11 +206,9 @@ type Model struct {
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle) lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
tabSeq int // seq of the newest issued tab-data load tabSeq int // seq of the newest issued tab-data load
logsOpen bool bottomPanel bottomPanel
detailOpen bool detailOpen bool
maintOpen bool
maintCursor int maintCursor int
maintOffset int
detailChanges []models.StateChange detailChanges []models.StateChange
detailChangesSiteID int detailChangesSiteID int
detailDailyDays []monitor.DayReport detailDailyDays []monitor.DayReport
@@ -249,7 +255,7 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
theme: theme, theme: theme,
themeIndex: themeIdx, themeIndex: themeIdx,
st: newStyles(theme), st: newStyles(theme),
logsOpen: true, bottomPanel: bottomLogs,
detailOpen: detailPref == "true", detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1", demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version, version: version,
+31 -33
View File
@@ -157,7 +157,7 @@ func (m *Model) recalcLayout() {
if m.filterMode || m.filterText != "" { if m.filterMode || m.filterText != "" {
chrome++ chrome++
} }
if m.logsOpen { if m.bottomPanel != bottomNone {
chrome += logsStripHeight chrome += logsStripHeight
} }
m.maxTableRows = m.termHeight - chrome 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.sortAsc = !m.sortAsc
m.refreshLive() m.refreshLive()
case "m": case "m":
if m.termWidth >= wideBreakpoint { if m.bottomPanel == bottomMaint {
if m.focusedPanel == panelMaint { m.bottomPanel = bottomNone
m.maintOpen = false
m.focusedPanel = panelMonitors
} else {
m.maintOpen = true
m.focusedPanel = panelMaint
}
m.recalcLayout()
}
case "l":
if m.logsOpen {
m.logsOpen = false
m.focusedPanel = panelMonitors m.focusedPanel = panelMonitors
} else { } 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.focusedPanel = panelLogs
} }
m.recalcLayout() 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 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 return m, nil
} else if m.zones.Get("panel-monitors").InBounds(msg) { } else if m.zones.Get("panel-monitors").InBounds(msg) {
m.focusedPanel = panelMonitors 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) { } else if m.zones.Get("panel-logs").InBounds(msg) {
m.focusedPanel = panelLogs m.focusedPanel = panelLogs
return m, nil return m, nil
@@ -881,23 +896,6 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m, nil 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 return m, nil
} }
+14 -24
View File
@@ -143,28 +143,18 @@ func (m Model) computeStats() dashboardStats {
return s return s
} }
const maintSidebarW = 22
func (m Model) viewMonitorsLayout() string { func (m Model) viewMonitorsLayout() string {
availW := m.termWidth - chromePadH availW := m.termWidth - chromePadH
wide := m.termWidth >= wideBreakpoint wide := m.termWidth >= wideBreakpoint
showMaint := m.maintOpen && wide
showDetail := m.detailOpen && wide showDetail := m.detailOpen && wide
var maintW, detailW, monW int var detailW, monW int
if showMaint {
maintW = maintSidebarW
if maintW > availW/4 {
maintW = availW / 4
}
}
remaining := availW - maintW
if showDetail { if showDetail {
monW = remaining * 60 / 100 monW = availW * 60 / 100
detailW = remaining - monW detailW = availW - monW
} else { } else {
monW = remaining monW = availW
} }
m.contentWidth = monW - 2 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)) monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, monW, m.focusedPanel == panelMonitors))
var topParts []string 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) topParts = append(topParts, monPanel)
if showDetail { if showDetail {
title := "" title := ""
@@ -199,10 +184,15 @@ func (m Model) viewMonitorsLayout() string {
top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...) top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...)
if m.logsOpen { switch m.bottomPanel {
case bottomLogs:
logContent := m.viewLogsStrip(availW-2, logsStripHeight-2) logContent := m.viewLogsStrip(availW-2, logsStripHeight-2)
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, availW, m.focusedPanel == panelLogs)) logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, availW, m.focusedPanel == panelLogs))
return top + "\n" + logPanel 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 return top
} }
@@ -296,7 +286,7 @@ func (m Model) renderFooter(_ dashboardStats) string {
var parts []string var parts []string
if m.focusedPanel == panelMaint { 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 { } else if m.focusedPanel == panelLogs {
parts = []string{m.hotkey("↑/↓", "Scroll"), m.hotkey("Enter", "Expand"), m.hotkey("l/Esc", "Back")} parts = []string{m.hotkey("↑/↓", "Scroll"), m.hotkey("Enter", "Expand"), m.hotkey("l/Esc", "Back")}
} else if m.detailOpen && m.detailMode == detailSLA { } else if m.detailOpen && m.detailMode == detailSLA {
@@ -304,7 +294,7 @@ func (m Model) renderFooter(_ dashboardStats) string {
} else if m.detailOpen && m.detailMode == detailHistory { } else if m.detailOpen && m.detailMode == detailHistory {
parts = []string{m.hotkey("Esc", "Back")} parts = []string{m.hotkey("Esc", "Back")}
} else if m.detailOpen { } 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 { } else {
parts = []string{m.hotkey("/", "Filter"), m.hotkey("Enter", "Detail")} parts = []string{m.hotkey("/", "Filter"), m.hotkey("Enter", "Detail")}
if m.cursor < len(m.sites) && m.sites[m.cursor].Type == "group" { 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("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) line := strings.Join(parts, dot)
if m.filterText != "" { if m.filterText != "" {
+58 -52
View File
@@ -7,6 +7,7 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
) )
func (m Model) viewMaintDetailPanel() string { func (m Model) viewMaintDetailPanel() string {
@@ -85,6 +86,15 @@ func (m Model) viewMaintDetailPanel() string {
return lipgloss.NewStyle().Padding(1, 2).Render(b.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 { func (m Model) activeMaintWindows() []models.MaintenanceWindow {
now := time.Now() now := time.Now()
var out []models.MaintenanceWindow var out []models.MaintenanceWindow
@@ -97,34 +107,26 @@ func (m Model) activeMaintWindows() []models.MaintenanceWindow {
return out return out
} }
func (m Model) viewMaintSidebar(width, maxLines int) string { func (m Model) viewMaintStrip(width, maxLines int) string {
windows := m.activeMaintWindows() windows := m.activeMaintWindows()
if len(windows) == 0 { 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() now := time.Now()
for i := start; i < end; i++ { end := maxLines
mw := windows[i] if end > len(windows) {
selected := m.focusedPanel == panelMaint && i == m.maintCursor 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)) isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
var icon string var icon string
@@ -134,37 +136,51 @@ func (m Model) viewMaintSidebar(width, maxLines int) string {
icon = m.st.warnStyle.Render("○") icon = m.st.warnStyle.Render("○")
} }
title := limitStr(mw.Title, contentW-3) monName := "All Monitors"
titleLine := " " + icon + " " + title if mw.MonitorID > 0 {
monName = m.monitorNameByID(mw.MonitorID)
}
var detail string var status string
if isActive { if isActive {
if mw.EndTime.IsZero() { if mw.EndTime.IsZero() {
detail = m.st.subtleStyle.Render("active · indefinite") status = "indefinite"
} else { } else {
remaining := time.Until(mw.EndTime) status = fmtDuration(time.Until(mw.EndTime)) + " left"
detail = m.st.subtleStyle.Render("active · " + fmtDuration(remaining))
} }
} else { } else {
detail = m.st.subtleStyle.Render(mw.StartTime.Format("Jan 02") + " · " + fmtDuration(mw.EndTime.Sub(mw.StartTime))) status = "starts " + mw.StartTime.Format("Jan 02 15:04")
}
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))
} }
lines = append(lines, titleLine, detailLine) rows = append(rows, []string{icon, mw.Title, monName, status})
} }
if len(windows) > end-start { colWidths := []int{3, 0, 0, 0}
more := fmt.Sprintf(" %d more", len(windows)-(end-start)) remaining := width - colWidths[0] - 6
lines = append(lines, m.st.subtleStyle.Render(more)) 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) { func (m *Model) scrollMaintCursor(delta int) {
@@ -180,14 +196,4 @@ func (m *Model) scrollMaintCursor(delta int) {
if m.maintCursor >= total { if m.maintCursor >= total {
m.maintCursor = total - 1 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
}
} }