Compare commits

...

6 Commits

Author SHA1 Message Date
lerko 6e936ecce3 style(tui): adjust monitor/detail split to 60/40
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
Gives monitors panel more room for columns while detail sidebar
content stacks vertically and doesn't need the extra width.
2026-06-30 21:12:00 -04:00
lerko 04cf12f52b style(tui): match detail panel footer to hotbar styling
Detail panel footer now uses accent keys, muted descriptions, and dot
separators — same visual style as the bottom hotbar.
2026-06-30 21:07:47 -04:00
lerko 33df597dda fix(tui): merge SLA footer into single line
Period keys and Esc Back now render on one line instead of two.
2026-06-30 20:59:04 -04:00
lerko 1d14f640f4 feat(tui): pin detail panel footer keys at bottom of sidebar
CI / test (pull_request) Successful in 1m53s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 1m1s
Footer keys (period selector, edit/history/SLA shortcuts, Esc) are now
pinned at the bottom of the detail panel, always visible regardless of
scroll position. Scrollable content fills the space between the header
and the pinned footer.
2026-06-30 20:46:43 -04:00
lerko 631f07c242 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.
2026-06-30 20:36:25 -04:00
lerko 0badc2ddf5 fix(tui): cap detail sidebar height to match monitors panel
CI / test (pull_request) Successful in 1m51s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
SLA daily breakdown no longer overflows past the monitors panel. Detail
sidebar height is capped to monitors panel height via titledPanelH.
SLA daily rows are limited to fit within available space. Logs strip
stays visible in all detail modes.
2026-06-30 20:18:39 -04:00
6 changed files with 152 additions and 35 deletions
+79
View File
@@ -6,6 +6,85 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, focused bool) string {
if height <= 0 {
return m.titledPanel(title, content, width, focused)
}
borderColor := m.theme.Border
titleColor := m.theme.Muted
if focused {
borderColor = m.theme.Accent
titleColor = m.theme.Accent
}
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")
var footerLines []string
if footer != "" {
footerRendered := contentStyle.Render(footer)
footerLines = strings.Split(footerRendered, "\n")
}
bodyH := height - 2 - len(footerLines)
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]
borderLine := func(line string) string {
return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + bc.Render("│")
}
emptyLine := borderLine(strings.Repeat(" ", innerW))
var lines []string
lines = append(lines, top)
for _, line := range visible {
lines = append(lines, borderLine(line))
}
for len(lines) < height-1-len(footerLines) {
lines = append(lines, emptyLine)
}
for _, line := range footerLines {
lines = append(lines, borderLine(line))
}
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 {
borderColor := m.theme.Border borderColor := m.theme.Border
titleColor := m.theme.Muted titleColor := m.theme.Muted
+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
@@ -142,7 +142,7 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
} }
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
_ = m.viewDetailInline(80) _ = m.viewDetailInline(80, 30)
} }
if ms.stateChangeCalls != 1 { if ms.stateChangeCalls != 1 {
t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls) t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls)
+5 -3
View File
@@ -161,7 +161,7 @@ func (m Model) viewMonitorsLayout() string {
} }
remaining := availW - maintW remaining := availW - maintW
if showDetail { if showDetail {
monW = remaining * 45 / 100 monW = remaining * 60 / 100
detailW = remaining - monW detailW = remaining - monW
} else { } else {
monW = remaining monW = remaining
@@ -190,8 +190,10 @@ func (m Model) viewMonitorsLayout() string {
case detailHistory: case detailHistory:
title = "History · " + title title = "History · " + title
} }
detail := m.viewDetailInline(detailW - 2) monHeight := lipgloss.Height(monPanel)
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(title, detail, detailW, m.focusedPanel == panelDetail)) detail := m.viewDetailInline(detailW-2, monHeight)
footer := m.detailFooter(detailW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail))
topParts = append(topParts, detailPanel) topParts = append(topParts, detailPanel)
} }
+29 -26
View File
@@ -10,23 +10,23 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
func (m Model) viewDetailInline(width int) string { func (m Model) viewDetailInline(width, height int) string {
if m.cursor >= len(m.sites) { if m.cursor >= len(m.sites) {
return "" return ""
} }
switch m.detailMode { switch m.detailMode {
case detailSLA: case detailSLA:
return m.viewSLASidebar(width) return m.viewSLASidebar(width, height)
case detailHistory: case detailHistory:
return m.viewHistorySidebar(width) return m.viewHistorySidebar(width, height)
default: default:
site := m.sites[m.cursor] site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID) hist, _ := m.engine.GetHistory(site.ID)
return m.viewDetailSidebar(site, hist, width) return m.viewDetailSidebar(site, hist, width, height)
} }
} }
func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width int) string { func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width, _ int) string {
dot := m.st.subtleStyle.Render(" · ") dot := m.st.subtleStyle.Render(" · ")
label := m.st.subtleStyle label := m.st.subtleStyle
var b strings.Builder var b strings.Builder
@@ -143,8 +143,6 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
b.WriteString(" " + label.Render("No state changes") + "\n") b.WriteString(" " + label.Render("No state changes") + "\n")
} }
b.WriteString("\n " + m.detailKeys() + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
} }
@@ -212,8 +210,28 @@ func (m Model) detailTypeLine(site models.Site) []string {
return parts return parts
} }
func (m Model) detailKeys() string { func (m Model) detailFooter(width int) string {
return m.st.subtleStyle.Render("[e] Edit [h] History [s] SLA [Esc] Back") dot := m.st.subtleStyle.Render(" · ")
var parts []string
switch m.detailMode {
case detailSLA:
for i, p := range slaPeriods {
if i == m.slaPeriodIdx {
parts = append(parts, m.st.titleStyle.Render(p.key)+" "+m.st.titleStyle.Render(p.label))
} else {
parts = append(parts, m.hotkey(p.key, p.label))
}
}
parts = append(parts, m.hotkey("Esc", "Back"))
case detailHistory:
parts = append(parts, m.hotkey("Esc", "Back"))
default:
parts = append(parts, m.hotkey("e", "Edit"), m.hotkey("h", "History"), m.hotkey("s", "SLA"), m.hotkey("Esc", "Back"))
}
content := " " + strings.Join(parts, dot)
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(content)
} }
func (m Model) fmtStatusWord(status string) string { func (m Model) fmtStatusWord(status string) string {
@@ -235,7 +253,7 @@ func (m Model) fmtStatusWord(status string) string {
} }
} }
func (m Model) viewSLASidebar(width 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
@@ -292,24 +310,10 @@ func (m Model) viewSLASidebar(width int) string {
} }
} }
b.WriteString("\n")
var keys []string
for i, p := range slaPeriods {
k := fmt.Sprintf("[%s] %s", p.key, p.label)
if i == m.slaPeriodIdx {
keys = append(keys, m.st.titleStyle.Render(k))
} else {
keys = append(keys, label.Render(k))
}
}
b.WriteString(" " + strings.Join(keys, " ") + "\n")
b.WriteString(" " + label.Render("[Esc] Back") + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
} }
func (m Model) viewHistorySidebar(width int) string { func (m Model) viewHistorySidebar(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
@@ -368,7 +372,6 @@ func (m Model) viewHistorySidebar(width int) string {
statParts = append(statParts, "avg "+fmtDuration(avg)) statParts = append(statParts, "avg "+fmtDuration(avg))
} }
b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n") b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n")
b.WriteString(" " + label.Render("[Esc] Back") + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
} }