refactor(tui): extract shared table rendering, fix cursor bounds

- New table_helpers.go with renderTable() and shared styles
- Remove 4 duplicated style blocks (header/cell/selected/border)
  from tab_alerts.go and tab_users.go
- All 3 tab views now use renderTable() for offset/end calc,
  selected row highlighting, and table construction
- Sites tab keeps siteGroupStyle via StyleOverride callback
- Clamp cursor to list length at end of refreshData() to prevent
  index-out-of-bounds after concurrent list changes
- Fix off-by-one in tab click handler (i <= maxTabs → i < tabCount)
This commit is contained in:
2026-05-15 00:49:14 -04:00
parent d6f33a4d1f
commit 0e6dc774cb
5 changed files with 204 additions and 249 deletions
+20 -61
View File
@@ -7,25 +7,6 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
var (
alertHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true).
Padding(0, 1)
alertCellStyle = lipgloss.NewStyle().Padding(0, 1)
alertSelectedStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#3b3b5c"))
alertBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444"))
) )
type alertFormData struct { type alertFormData struct {
@@ -97,49 +78,27 @@ func (m Model) viewAlertsTab() string {
return "\n No alert channels configured. Press [n] to add one." return "\n No alert channels configured. Press [n] to add one."
} }
end := m.tableOffset + m.maxTableRows return m.renderTable(
if end > len(m.alerts) { []string{"#", "NAME", "TYPE", "CONFIG"},
end = len(m.alerts) len(m.alerts),
} func(start, end int) [][]string {
var rows [][]string
selectedVisual := m.cursor - m.tableOffset for i := start; i < end; i++ {
a := m.alerts[i]
var rows [][]string rows = append(rows, []string{
for i := m.tableOffset; i < end; i++ { fmt.Sprintf("%d", i+1),
alert := m.alerts[i] m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, 15)),
rows = append(rows, []string{ fmtAlertType(a.Type),
fmt.Sprintf("%d", i+1), fmtAlertConfig(struct {
m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(alert.Name, 15)), Type string
fmtAlertType(alert.Type), Settings map[string]string
fmtAlertConfig(struct { }{a.Type, a.Settings}),
Type string })
Settings map[string]string
}{alert.Type, alert.Settings}),
})
}
tableWidth := m.termWidth - 6
if tableWidth < 40 {
tableWidth = 40
}
t := table.New().
Border(lipgloss.RoundedBorder()).
BorderStyle(alertBorderStyle).
Width(tableWidth).
Headers("#", "NAME", "TYPE", "CONFIG").
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return alertHeaderStyle
} }
if row == selectedVisual { return rows
return alertSelectedStyle },
} nil, nil,
return alertCellStyle )
})
return "\n" + t.Render()
} }
func (m *Model) initAlertHuhForm() tea.Cmd { func (m *Model) initAlertHuhForm() tea.Cmd {
+75 -125
View File
@@ -12,33 +12,14 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
) )
var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
var ( var siteGroupStyle = lipgloss.NewStyle().
siteHeaderStyle = lipgloss.NewStyle(). Padding(0, 1).
Foreground(lipgloss.Color("#7D56F4")). Bold(true).
Bold(true). Foreground(lipgloss.Color("#7D56F4"))
Padding(0, 1)
siteCellStyle = lipgloss.NewStyle().Padding(0, 1)
siteSelectedStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#3b3b5c"))
siteBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444"))
siteGroupStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#7D56F4"))
)
type siteFormData struct { type siteFormData struct {
Name string Name string
@@ -219,111 +200,80 @@ func (m Model) viewSitesTab() string {
return "\n No sites configured. Press [n] to add one." return "\n No sites configured. Press [n] to add one."
} }
end := m.tableOffset + m.maxTableRows
if end > len(m.sites) {
end = len(m.sites)
}
selectedVisual := m.cursor - m.tableOffset
var rows [][]string
var groupRows []int
for i := m.tableOffset; i < end; i++ {
site := m.sites[i]
if site.Type == "group" {
groupRows = append(groupRows, i-m.tableOffset)
arrow := "▾"
if m.collapsed[site.ID] {
arrow = "▸"
}
rows = append(rows, []string{
strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, 11)),
"group",
fmtStatus(site.Status, site.Paused),
subtleStyle.Render("—"),
subtleStyle.Render("—"),
subtleStyle.Render(strings.Repeat("·", sparkWidth)),
subtleStyle.Render("-"),
subtleStyle.Render("—"),
})
continue
}
name := site.Name
if site.ParentID > 0 {
prefix := "├"
if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID {
prefix = "└"
}
name = prefix + " " + limitStr(name, 11)
} else {
name = limitStr(name, 13)
}
hist, _ := monitor.GetHistory(site.ID)
var spark string
if site.Type == "push" {
spark = heartbeatSparkline(hist.Statuses, sparkWidth)
} else {
spark = latencySparkline(hist.Latencies, sparkWidth)
}
rows = append(rows, []string{
strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("site-%d", i), name),
site.Type,
fmtStatus(site.Status, site.Paused),
fmtLatency(site.Latency),
fmtUptime(hist.TotalChecks, hist.UpChecks),
spark,
fmtSSL(site),
fmtRetries(site),
})
}
isGroupRow := func(row int) bool {
for _, g := range groupRows {
if g == row {
return true
}
}
return false
}
tableWidth := m.termWidth - 6
if tableWidth < 40 {
tableWidth = 40
}
// column widths: #=6, name=flex, type=10, status=10, latency=8, uptime=8, history=sparkWidth+4, ssl=7, retry=9
colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 4, 7, 9} colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 4, 7, 9}
t := table.New(). var groupRows map[int]bool
Border(lipgloss.RoundedBorder()). return m.renderTable(
BorderStyle(siteBorderStyle). []string{"#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY"},
Width(tableWidth). len(m.sites),
Headers("#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY"). func(start, end int) [][]string {
Rows(rows...). groupRows = make(map[int]bool)
StyleFunc(func(row, col int) lipgloss.Style { var rows [][]string
var base lipgloss.Style for i := start; i < end; i++ {
if row == table.HeaderRow { site := m.sites[i]
base = siteHeaderStyle
} else if row == selectedVisual {
base = siteSelectedStyle
} else if isGroupRow(row) {
base = siteGroupStyle
} else {
base = siteCellStyle
}
if col < len(colWidths) && colWidths[col] > 0 {
base = base.Width(colWidths[col])
}
return base
})
return "\n" + t.Render() if site.Type == "group" {
groupRows[i-start] = true
arrow := "▾"
if m.collapsed[site.ID] {
arrow = "▸"
}
rows = append(rows, []string{
strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, 11)),
"group",
fmtStatus(site.Status, site.Paused),
subtleStyle.Render("—"),
subtleStyle.Render("—"),
subtleStyle.Render(strings.Repeat("·", sparkWidth)),
subtleStyle.Render("-"),
subtleStyle.Render("—"),
})
continue
}
name := site.Name
if site.ParentID > 0 {
prefix := "├"
if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID {
prefix = "└"
}
name = prefix + " " + limitStr(name, 11)
} else {
name = limitStr(name, 13)
}
hist, _ := monitor.GetHistory(site.ID)
var spark string
if site.Type == "push" {
spark = heartbeatSparkline(hist.Statuses, sparkWidth)
} else {
spark = latencySparkline(hist.Latencies, sparkWidth)
}
rows = append(rows, []string{
strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("site-%d", i), name),
site.Type,
fmtStatus(site.Status, site.Paused),
fmtLatency(site.Latency),
fmtUptime(hist.TotalChecks, hist.UpChecks),
spark,
fmtSSL(site),
fmtRetries(site),
})
}
return rows
},
colWidths,
func(row, col int) *lipgloss.Style {
if groupRows[row] {
s := siteGroupStyle
return &s
}
return nil
},
)
} }
func (m *Model) initSiteHuhForm() tea.Cmd { func (m *Model) initSiteHuhForm() tea.Cmd {
+17 -59
View File
@@ -6,26 +6,6 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
var (
userHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true).
Padding(0, 1)
userCellStyle = lipgloss.NewStyle().Padding(0, 1)
userSelectedStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#3b3b5c"))
userBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444"))
) )
type userFormData struct { type userFormData struct {
@@ -53,46 +33,24 @@ func (m Model) viewUsersTab() string {
return "\n No users configured. Press [n] to add one." return "\n No users configured. Press [n] to add one."
} }
end := m.tableOffset + m.maxTableRows return m.renderTable(
if end > len(m.users) { []string{"#", "USERNAME", "ROLE", "PUBLIC KEY"},
end = len(m.users) len(m.users),
} func(start, end int) [][]string {
var rows [][]string
selectedVisual := m.cursor - m.tableOffset for i := start; i < end; i++ {
u := m.users[i]
var rows [][]string rows = append(rows, []string{
for i := m.tableOffset; i < end; i++ { fmt.Sprintf("%d", i+1),
u := m.users[i] m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)),
rows = append(rows, []string{ fmtRole(u.Role),
fmt.Sprintf("%d", i+1), fmtKey(u.PublicKey),
m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)), })
fmtRole(u.Role),
fmtKey(u.PublicKey),
})
}
tableWidth := m.termWidth - 6
if tableWidth < 40 {
tableWidth = 40
}
t := table.New().
Border(lipgloss.RoundedBorder()).
BorderStyle(userBorderStyle).
Width(tableWidth).
Headers("#", "USERNAME", "ROLE", "PUBLIC KEY").
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return userHeaderStyle
} }
if row == selectedVisual { return rows
return userSelectedStyle },
} nil, nil,
return userCellStyle )
})
return "\n" + t.Render()
} }
func (m *Model) initUserHuhForm() tea.Cmd { func (m *Model) initUserHuhForm() tea.Cmd {
+75
View File
@@ -0,0 +1,75 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
var (
tableHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true).
Padding(0, 1)
tableCellStyle = lipgloss.NewStyle().Padding(0, 1)
tableSelectedStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#3b3b5c"))
tableBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444"))
)
type StyleOverride func(row, col int) *lipgloss.Style
func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string {
if items == 0 {
return ""
}
end := m.tableOffset + m.maxTableRows
if end > items {
end = items
}
selectedVisual := m.cursor - m.tableOffset
rows := buildRows(m.tableOffset, end)
tableWidth := m.termWidth - 6
if tableWidth < 40 {
tableWidth = 40
}
t := table.New().
Border(lipgloss.RoundedBorder()).
BorderStyle(tableBorderStyle).
Width(tableWidth).
Headers(headers...).
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return tableHeaderStyle
}
if styleOverride != nil {
if s := styleOverride(row, col); s != nil {
if col < len(colWidths) && colWidths[col] > 0 {
return s.Width(colWidths[col])
}
return *s
}
}
base := tableCellStyle
if row == selectedVisual {
base = tableSelectedStyle
}
if col < len(colWidths) && colWidths[col] > 0 {
base = base.Width(colWidths[col])
}
return base
})
return "\n" + t.Render()
}
+17 -4
View File
@@ -346,11 +346,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
maxTabs := 3 tabCount := 3
if !m.isAdmin { if m.isAdmin {
maxTabs = 2 tabCount = 4
} }
for i := 0; i <= maxTabs; i++ { for i := 0; i < tabCount; i++ {
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
m.switchTab(i) m.switchTab(i)
return m, nil return m, nil
@@ -477,6 +477,19 @@ func (m *Model) refreshData() {
} }
} }
m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n")) m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n"))
listLen := len(m.sites)
if m.currentTab == 1 {
listLen = len(m.alerts)
} else if m.currentTab == 3 {
listLen = len(m.users)
}
if listLen > 0 && m.cursor >= listLen {
m.cursor = listLen - 1
}
if m.cursor < m.tableOffset {
m.tableOffset = m.cursor
}
} }
func (m *Model) submitForm() { func (m *Model) submitForm() {