fix(tui): group selection highlight, layout constants, group history graphs

Group rows now show selection background when navigated to. Layout
chrome extracted to named constants to prevent viewport drift. Groups
display aggregate history as dot sparkline (●) distinct from site
bar sparklines, with uptime computed from active children only.
Paused and maintenance children excluded from group aggregates.
This commit is contained in:
2026-05-22 20:26:49 -04:00
parent 8e948bf187
commit 88e4f0ed69
3 changed files with 114 additions and 13 deletions
+90 -3
View File
@@ -132,6 +132,93 @@ func heartbeatSparkline(statuses []bool, width int) string {
return sb.String() return sb.String()
} }
func (m Model) groupSparkline(groupID int, width int) string {
allSites := m.engine.GetAllSites()
var childStatuses [][]bool
for _, s := range allSites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
if len(hist.Statuses) > 0 {
childStatuses = append(childStatuses, hist.Statuses)
}
}
}
if len(childStatuses) == 0 {
return subtleStyle.Render(strings.Repeat("·", width))
}
maxLen := 0
for _, s := range childStatuses {
if len(s) > maxLen {
maxLen = len(s)
}
}
if maxLen > width {
maxLen = width
}
aggregated := make([]bool, maxLen)
for i := 0; i < maxLen; i++ {
allUp := true
for _, statuses := range childStatuses {
idx := len(statuses) - maxLen + i
if idx >= 0 && !statuses[idx] {
allUp = false
break
}
}
aggregated[i] = allUp
}
var sb strings.Builder
if remaining := width - len(aggregated); remaining > 0 {
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
}
for _, up := range aggregated {
if up {
sb.WriteString(specialStyle.Render("●"))
} else {
sb.WriteString(dangerStyle.Render("●"))
}
}
return sb.String()
}
func (m Model) groupUptime(groupID int) string {
allSites := m.engine.GetAllSites()
var allStatuses [][]bool
for _, s := range allSites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
if len(hist.Statuses) > 0 {
allStatuses = append(allStatuses, hist.Statuses)
}
}
}
if len(allStatuses) == 0 {
return subtleStyle.Render("—")
}
total, up := 0, 0
for _, statuses := range allStatuses {
for _, s := range statuses {
total++
if s {
up++
}
}
}
return fmtUptime(func() []bool {
out := make([]bool, total)
idx := 0
for _, statuses := range allStatuses {
copy(out[idx:], statuses)
idx += len(statuses)
}
return out
}())
}
func fmtLatency(d time.Duration) string { func fmtLatency(d time.Duration) string {
ms := d.Milliseconds() ms := d.Milliseconds()
if ms == 0 { if ms == 0 {
@@ -227,7 +314,7 @@ func fmtStatus(status string, paused bool, inMaint bool) string {
func (m Model) dynamicWidths() (nameW, sparkW int) { func (m Model) dynamicWidths() (nameW, sparkW int) {
fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY
overhead := 30 // cell padding + borders overhead := 30 // cell padding + borders
avail := m.termWidth - 6 - fixed - overhead avail := m.termWidth - chromePadH - 2 - fixed - overhead
if avail < 30 { if avail < 30 {
avail = 30 avail = 30
} }
@@ -285,8 +372,8 @@ func (m Model) viewSitesTab() string {
"group", "group",
fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
subtleStyle.Render("—"), subtleStyle.Render("—"),
subtleStyle.Render("—"), m.groupUptime(site.ID),
subtleStyle.Render(strings.Repeat("·", sparkWidth)), m.groupSparkline(site.ID, sparkWidth),
subtleStyle.Render("-"), subtleStyle.Render("-"),
subtleStyle.Render("—"), subtleStyle.Render("—"),
}) })
+10 -5
View File
@@ -38,7 +38,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
selectedVisual := m.cursor - m.tableOffset selectedVisual := m.cursor - m.tableOffset
rows := buildRows(m.tableOffset, end) rows := buildRows(m.tableOffset, end)
tableWidth := m.termWidth - 6 tableWidth := m.termWidth - chromePadH - 2
if tableWidth < 40 { if tableWidth < 40 {
tableWidth = 40 tableWidth = 40
} }
@@ -53,16 +53,21 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
if row == table.HeaderRow { if row == table.HeaderRow {
return tableHeaderStyle return tableHeaderStyle
} }
isSelected := row == selectedVisual
if styleOverride != nil { if styleOverride != nil {
if s := styleOverride(row, col); s != nil { if s := styleOverride(row, col); s != nil {
if col < len(colWidths) && colWidths[col] > 0 { style := *s
return s.Width(colWidths[col]) if isSelected {
style = tableSelectedStyle.Foreground(s.GetForeground())
} }
return *s if col < len(colWidths) && colWidths[col] > 0 {
style = style.Width(colWidths[col])
}
return style
} }
} }
base := tableCellStyle base := tableCellStyle
if row == selectedVisual { if isSelected {
base = tableSelectedStyle base = tableSelectedStyle
} }
if col < len(colWidths) && colWidths[col] > 0 { if col < len(colWidths) && colWidths[col] > 0 {
+14 -5
View File
@@ -31,6 +31,16 @@ var (
var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
const (
chromePadV = 2 // outer Padding(1,2): 1 top + 1 bottom
chromePadH = 4 // outer Padding(1,2): 2 left + 2 right
chromeHeader = 1 // tab bar line
chromeGaps = 2 // "\n" separators: before content + before footer
chromeFooter = 2 // footer: "\n" prefix + text line
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
)
type sessionState int type sessionState int
const ( const (
@@ -198,17 +208,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.termWidth = msg.Width m.termWidth = msg.Width
m.termHeight = msg.Height m.termHeight = msg.Height
// Chrome: 1 top pad + 1 tabs + 2 newlines + 3 table borders + 1 table header + 1 footer + 1 bottom pad = 10 chrome := chromeBase
chrome := 10 if m.filterMode || m.filterText != "" {
if m.filterText != "" {
chrome++ chrome++
} }
m.maxTableRows = msg.Height - chrome m.maxTableRows = msg.Height - chrome
if m.maxTableRows < 1 { if m.maxTableRows < 1 {
m.maxTableRows = 1 m.maxTableRows = 1
} }
m.logViewport.Width = msg.Width - 4 m.logViewport.Width = msg.Width - chromePadH
m.logViewport.Height = msg.Height - 8 m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter)
return m, tea.ClearScreen return m, tea.ClearScreen
case time.Time: case time.Time: