feat(tui): stable detail panel height with scroll support
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:
+60
-7
@@ -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 {
|
||||||
|
|||||||
+6
-5
@@ -160,11 +160,12 @@ type Model struct {
|
|||||||
historySiteName string
|
historySiteName string
|
||||||
historySiteID int
|
historySiteID int
|
||||||
|
|
||||||
slaReport monitor.SLAReport
|
slaReport monitor.SLAReport
|
||||||
slaDailyBreakdown []monitor.DayReport
|
slaDailyBreakdown []monitor.DayReport
|
||||||
slaSiteName string
|
slaSiteName string
|
||||||
slaSiteID int
|
slaSiteID int
|
||||||
slaPeriodIdx int
|
slaPeriodIdx int
|
||||||
|
detailScrollOffset int
|
||||||
|
|
||||||
isAdmin bool
|
isAdmin bool
|
||||||
zones *zone.Manager
|
zones *zone.Manager
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) + "%"
|
||||||
|
|||||||
Reference in New Issue
Block a user