feat(tui): stable detail panel height with scroll support
CI / test (pull_request) Successful in 1m56s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 56s

Detail sidebar now maintains consistent height matching the monitors
panel across all modes (default, SLA, history). Content scrolls within
the fixed frame via mouse wheel or j/k when the detail panel is
focused. Scroll offset resets on mode/monitor/period changes. Logs
strip stays visible in all states.
This commit is contained in:
2026-06-30 20:36:25 -04:00
parent 0badc2ddf5
commit 631f07c242
5 changed files with 101 additions and 29 deletions
+60 -7
View File
@@ -6,16 +6,69 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
func (m Model) titledPanelH(title, content string, width, height int, focused bool) string { func (m Model) titledPanelH(title, content string, width, height, scrollOffset int, focused bool) string {
s := m.titledPanel(title, content, width, focused)
if height <= 0 { if height <= 0 {
return s return m.titledPanel(title, content, width, focused)
} }
lines := strings.Split(s, "\n")
if len(lines) <= height { borderColor := m.theme.Border
return s titleColor := m.theme.Muted
if focused {
borderColor = m.theme.Accent
titleColor = m.theme.Accent
} }
return strings.Join(lines[:height-1], "\n") + "\n" + lines[len(lines)-1]
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)+"╮")
bottom := bc.Render("╰" + strings.Repeat("─", innerW) + "╯")
contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW)
inner := contentStyle.Render(content)
contentLines := strings.Split(inner, "\n")
bodyH := height - 2
if bodyH < 1 {
bodyH = 1
}
if scrollOffset > len(contentLines)-bodyH {
scrollOffset = len(contentLines) - bodyH
}
if scrollOffset < 0 {
scrollOffset = 0
}
end := scrollOffset + bodyH
if end > len(contentLines) {
end = len(contentLines)
}
visible := contentLines[scrollOffset:end]
var lines []string
lines = append(lines, top)
for _, line := range visible {
lines = append(lines, bc.Render("│")+line+strings.Repeat(" ", max(0, innerW-lipgloss.Width(line)))+bc.Render("│"))
}
for len(lines) < height-1 {
lines = append(lines, bc.Render("│")+strings.Repeat(" ", innerW)+bc.Render("│"))
}
lines = append(lines, bottom)
return strings.Join(lines, "\n")
} }
func (m Model) titledPanel(title, content string, width int, focused bool) string { func (m Model) titledPanel(title, content string, width int, focused bool) string {
+1
View File
@@ -165,6 +165,7 @@ type Model struct {
slaSiteName string slaSiteName string
slaSiteID int slaSiteID int
slaPeriodIdx int slaPeriodIdx int
detailScrollOffset int
isAdmin bool isAdmin bool
zones *zone.Manager zones *zone.Manager
+32
View File
@@ -298,6 +298,18 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if m.focusedPanel == panelDetail && m.detailOpen {
if msg.Button == tea.MouseButtonWheelUp {
m.detailScrollOffset -= 3
} else {
m.detailScrollOffset += 3
}
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
return m, nil
}
listLen := m.currentListLen() listLen := m.currentListLen()
if msg.Button == tea.MouseButtonWheelUp { if msg.Button == tea.MouseButtonWheelUp {
if m.cursor > 0 { if m.cursor > 0 {
@@ -588,6 +600,13 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
m.recalcLayout() m.recalcLayout()
case "up", "k": case "up", "k":
if m.focusedPanel == panelDetail && m.detailOpen {
m.detailScrollOffset--
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
return m, nil
}
if m.focusedPanel == panelMaint { if m.focusedPanel == panelMaint {
m.scrollMaintCursor(-1) m.scrollMaintCursor(-1)
return m, nil return m, nil
@@ -604,10 +623,15 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.syncSelectedID() m.syncSelectedID()
if m.detailOpen && m.cursor < len(m.sites) { if m.detailOpen && m.cursor < len(m.sites) {
m.detailMode = detailDefault m.detailMode = detailDefault
m.detailScrollOffset = 0
return m, m.loadDetailCmd(m.sites[m.cursor].ID) return m, m.loadDetailCmd(m.sites[m.cursor].ID)
} }
} }
case "down", "j": case "down", "j":
if m.focusedPanel == panelDetail && m.detailOpen {
m.detailScrollOffset++
return m, nil
}
if m.focusedPanel == panelMaint { if m.focusedPanel == panelMaint {
m.scrollMaintCursor(1) m.scrollMaintCursor(1)
return m, nil return m, nil
@@ -625,6 +649,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.syncSelectedID() m.syncSelectedID()
if m.detailOpen && m.cursor < len(m.sites) { if m.detailOpen && m.cursor < len(m.sites) {
m.detailMode = detailDefault m.detailMode = detailDefault
m.detailScrollOffset = 0
return m, m.loadDetailCmd(m.sites[m.cursor].ID) return m, m.loadDetailCmd(m.sites[m.cursor].ID)
} }
} }
@@ -652,6 +677,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if len(m.sites) > 0 { if len(m.sites) > 0 {
m.detailOpen = !m.detailOpen m.detailOpen = !m.detailOpen
m.detailMode = detailDefault m.detailMode = detailDefault
m.detailScrollOffset = 0
m.recalcLayout() m.recalcLayout()
st := m.store st := m.store
ctx := m.ctx ctx := m.ctx
@@ -702,9 +728,11 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.focusedPanel = panelMonitors m.focusedPanel = panelMonitors
} else if m.detailOpen && m.detailMode != detailDefault { } else if m.detailOpen && m.detailMode != detailDefault {
m.detailMode = detailDefault m.detailMode = detailDefault
m.detailScrollOffset = 0
} else if m.detailOpen { } else if m.detailOpen {
m.detailOpen = false m.detailOpen = false
m.detailMode = detailDefault m.detailMode = detailDefault
m.detailScrollOffset = 0
m.recalcLayout() m.recalcLayout()
st := m.store st := m.store
ctx := m.ctx ctx := m.ctx
@@ -719,6 +747,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.historySiteID = site.ID m.historySiteID = site.ID
m.historyChanges = nil m.historyChanges = nil
m.detailMode = detailHistory m.detailMode = detailHistory
m.detailScrollOffset = 0
return m, m.loadHistoryCmd(site.ID) return m, m.loadHistoryCmd(site.ID)
} }
case "s": case "s":
@@ -728,6 +757,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.slaSiteID = site.ID m.slaSiteID = site.ID
m.slaPeriodIdx = 2 m.slaPeriodIdx = 2
m.detailMode = detailSLA m.detailMode = detailSLA
m.detailScrollOffset = 0
return m, m.loadSLACmd(site.ID, m.slaPeriodIdx) return m, m.loadSLACmd(site.ID, m.slaPeriodIdx)
} }
case "1", "2", "3", "4": case "1", "2", "3", "4":
@@ -735,6 +765,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
idx := int(msg.String()[0]-'0') - 1 idx := int(msg.String()[0]-'0') - 1
if idx >= 0 && idx < len(slaPeriods) { if idx >= 0 && idx < len(slaPeriods) {
m.slaPeriodIdx = idx m.slaPeriodIdx = idx
m.detailScrollOffset = 0
return m, m.loadSLACmd(m.slaSiteID, idx) return m, m.loadSLACmd(m.slaSiteID, idx)
} }
} }
@@ -860,6 +891,7 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
m.syncSelectedID() m.syncSelectedID()
m.focusedPanel = panelMonitors m.focusedPanel = panelMonitors
if m.detailOpen { if m.detailOpen {
m.detailScrollOffset = 0
return m, m.loadDetailCmd(m.sites[m.cursor].ID) return m, m.loadDetailCmd(m.sites[m.cursor].ID)
} }
return m, nil return m, nil
+1 -1
View File
@@ -192,7 +192,7 @@ func (m Model) viewMonitorsLayout() string {
} }
monHeight := lipgloss.Height(monPanel) monHeight := lipgloss.Height(monPanel)
detail := m.viewDetailInline(detailW-2, monHeight) detail := m.viewDetailInline(detailW-2, monHeight)
detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, detailW, monHeight, m.focusedPanel == panelDetail)) detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail))
topParts = append(topParts, detailPanel) topParts = append(topParts, detailPanel)
} }
+2 -16
View File
@@ -235,7 +235,7 @@ func (m Model) fmtStatusWord(status string) string {
} }
} }
func (m Model) viewSLASidebar(width, height int) string { func (m Model) viewSLASidebar(width, _ int) string {
var b strings.Builder var b strings.Builder
label := m.st.subtleStyle label := m.st.subtleStyle
innerW := width - 4 innerW := width - 4
@@ -277,21 +277,7 @@ func (m Model) viewSLASidebar(width, height int) string {
if dayBarW < 10 { if dayBarW < 10 {
dayBarW = 10 dayBarW = 10
} }
slaChrome := 14 for _, day := range m.slaDailyBreakdown {
if r.OutageCount > 0 {
slaChrome += 3
}
maxDaily := len(m.slaDailyBreakdown)
if height > 0 && height-slaChrome < maxDaily {
maxDaily = height - slaChrome
if maxDaily < 1 {
maxDaily = 1
}
}
for i, day := range m.slaDailyBreakdown {
if i >= maxDaily {
break
}
dateStr := day.Date.Format("Jan 02") dateStr := day.Date.Format("Jan 02")
dayBar := m.uptimeBar(day.UptimePct, dayBarW) dayBar := m.uptimeBar(day.UptimePct, dayBarW)
pctStr := fmtPct(day.UptimePct) + "%" pctStr := fmtPct(day.UptimePct) + "%"