fix(tui): visual polish and layout improvements #18

Merged
lerko merged 3 commits from fix/tui-visual-polish into main 2026-05-23 16:12:58 +00:00
8 changed files with 240 additions and 24 deletions
+2
View File
@@ -62,6 +62,8 @@ func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { retur
func (m *mockStore) EndMaintenanceWindow(int) error { return nil } func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil } func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil } func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
func (m *mockStore) SetPreference(string, string) error { return nil }
func TestMetricsHandler(t *testing.T) { func TestMetricsHandler(t *testing.T) {
ms := &mockStore{ ms := &mockStore{
+4
View File
@@ -67,6 +67,10 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
created_by TEXT DEFAULT '', created_by TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT NOW() created_at TIMESTAMP DEFAULT NOW()
)`, )`,
`CREATE TABLE IF NOT EXISTS preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
} }
} }
+4
View File
@@ -67,6 +67,10 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
created_by TEXT DEFAULT '', created_by TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, )`,
`CREATE TABLE IF NOT EXISTS preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
} }
} }
+18
View File
@@ -441,6 +441,24 @@ func (s *SQLStore) IsMonitorInMaintenance(monitorID int) (bool, error) {
return count > 0, nil return count > 0, nil
} }
func (s *SQLStore) GetPreference(key string) (string, error) {
var value string
err := s.db.QueryRow(s.q("SELECT value FROM preferences WHERE key = ?"), key).Scan(&value)
if err != nil {
return "", err
}
return value, nil
}
func (s *SQLStore) SetPreference(key, value string) error {
if s.dollar {
_, err := s.db.Exec(s.q("INSERT INTO preferences (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = ?"), key, value, value)
return err
}
_, err := s.db.Exec("INSERT OR REPLACE INTO preferences (key, value) VALUES (?, ?)", key, value)
return err
}
func (s *SQLStore) ExportData() (models.Backup, error) { func (s *SQLStore) ExportData() (models.Backup, error) {
sites, err := s.GetSites() sites, err := s.GetSites()
if err != nil { if err != nil {
+4
View File
@@ -57,6 +57,10 @@ type Store interface {
DeleteMaintenanceWindow(id int) error DeleteMaintenanceWindow(id int) error
IsMonitorInMaintenance(monitorID int) (bool, error) IsMonitorInMaintenance(monitorID int) (bool, error)
// Preferences
GetPreference(key string) (string, error)
SetPreference(key, value string) error
// Backup & Restore // Backup & Restore
ExportData() (models.Backup, error) ExportData() (models.Backup, error)
ImportData(data models.Backup) error ImportData(data models.Backup) error
+134 -7
View File
@@ -29,9 +29,9 @@ func typeIcon(siteType string, collapsed bool) string {
return "◆" return "◆"
case "group": case "group":
if collapsed { if collapsed {
return "" return ""
} }
return "" return ""
default: default:
return "·" return "·"
} }
@@ -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("—"),
}) })
@@ -619,8 +706,19 @@ func (m Model) viewDetailPanel() string {
var b strings.Builder var b strings.Builder
title := titleStyle.Render(fmt.Sprintf(" %s", site.Name)) var breadcrumb string
b.WriteString(title + "\n\n") if site.ParentID > 0 {
for _, s := range m.sites {
if s.ID == site.ParentID {
breadcrumb = subtleStyle.Render(" Sites > "+s.Name+" > ") + titleStyle.Render(site.Name)
break
}
}
}
if breadcrumb == "" {
breadcrumb = subtleStyle.Render(" Sites > ") + titleStyle.Render(site.Name)
}
b.WriteString(breadcrumb + "\n\n")
row := func(label, value string) { row := func(label, value string) {
b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value)) b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value))
@@ -690,8 +788,37 @@ func (m Model) viewDetailPanel() string {
const sparkWidth = 40 const sparkWidth = 40
if site.Type == "push" { if site.Type == "push" {
b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth)) b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth))
if len(hist.Statuses) > 0 {
up := 0
for _, s := range hist.Statuses {
if s {
up++
}
}
b.WriteString(fmt.Sprintf("\n %s %d/%d checks up",
subtleStyle.Render("Heartbeats"),
up, len(hist.Statuses)))
}
} else { } else {
b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth)) b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth))
if len(hist.Latencies) > 0 {
minL, maxL := hist.Latencies[0], hist.Latencies[0]
var total time.Duration
for _, l := range hist.Latencies {
total += l
if l < minL {
minL = l
}
if l > maxL {
maxL = l
}
}
avg := total / time.Duration(len(hist.Latencies))
b.WriteString(fmt.Sprintf("\n %s %dms %s %dms %s %dms",
subtleStyle.Render("Min"), minL.Milliseconds(),
subtleStyle.Render("Avg"), avg.Milliseconds(),
subtleStyle.Render("Max"), maxL.Milliseconds()))
}
} }
b.WriteString("\n\n") b.WriteString("\n\n")
+17 -5
View File
@@ -21,6 +21,10 @@ var (
tableBorderStyle = lipgloss.NewStyle(). tableBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444")) Foreground(lipgloss.Color("#444"))
tableZebraStyle = lipgloss.NewStyle().
Padding(0, 1).
Background(lipgloss.Color("#1a1a2e"))
) )
type StyleOverride func(row, col int) *lipgloss.Style type StyleOverride func(row, col int) *lipgloss.Style
@@ -38,7 +42,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 +57,24 @@ 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 row%2 == 1 {
base = tableZebraStyle
}
if isSelected {
base = tableSelectedStyle base = tableSelectedStyle
} }
if col < len(colWidths) && colWidths[col] > 0 { if col < len(colWidths) && colWidths[col] > 0 {
+57 -12
View File
@@ -1,6 +1,7 @@
package tui package tui
import ( import (
"encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"go-upkeep/internal/monitor" "go-upkeep/internal/monitor"
@@ -31,6 +32,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 (
@@ -95,6 +106,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
vpLogs.SetContent("Waiting for logs...") vpLogs.SetContent("Waiting for logs...")
z := zone.New() z := zone.New()
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4) spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
collapsed := loadCollapsed(s)
return Model{ return Model{
state: stateDashboard, state: stateDashboard,
logViewport: vpLogs, logViewport: vpLogs,
@@ -104,10 +116,37 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
engine: eng, engine: eng,
zones: z, zones: z,
pulseSpring: spring, pulseSpring: spring,
collapsed: make(map[int]bool), collapsed: collapsed,
} }
} }
func loadCollapsed(s store.Store) map[int]bool {
m := make(map[int]bool)
raw, err := s.GetPreference("collapsed_groups")
if err != nil || raw == "" {
return m
}
var ids []int
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
return m
}
for _, id := range ids {
m[id] = true
}
return m
}
func saveCollapsed(s store.Store, collapsed map[int]bool) {
var ids []int
for id, v := range collapsed {
if v {
ids = append(ids, id)
}
}
data, _ := json.Marshal(ids)
_ = s.SetPreference("collapsed_groups", string(data))
}
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t })) return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t }))
} }
@@ -198,17 +237,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:
@@ -392,6 +430,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" { if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
gid := m.sites[m.cursor].ID gid := m.sites[m.cursor].ID
m.collapsed[gid] = !m.collapsed[gid] m.collapsed[gid] = !m.collapsed[gid]
saveCollapsed(m.store, m.collapsed)
m.refreshData() m.refreshData()
} }
case "p": case "p":
@@ -725,8 +764,14 @@ func (m Model) View() string {
} }
func (m Model) viewDashboard() string { func (m Model) viewDashboard() string {
allSites := m.engine.GetAllSites()
totalMonitors := 0
downCount := 0 downCount := 0
for _, s := range m.sites { for _, s := range allSites {
if s.Type == "group" {
continue
}
totalMonitors++
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") { if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
downCount++ downCount++
} }
@@ -741,8 +786,8 @@ func (m Model) viewDashboard() string {
var sitesLabel string var sitesLabel string
if downCount > 0 { if downCount > 0 {
sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount) sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount)
} else if len(m.sites) > 0 { } else if totalMonitors > 0 {
sitesLabel = fmt.Sprintf("Sites (%d)", len(m.sites)) sitesLabel = fmt.Sprintf("Sites (%d)", totalMonitors)
} else { } else {
sitesLabel = "Sites" sitesLabel = "Sites"
} }
@@ -806,12 +851,12 @@ func (m Model) viewDashboard() string {
} }
} }
upCount := len(m.sites) - downCount upCount := totalMonitors - downCount
var upStr string var upStr string
if downCount > 0 { if downCount > 0 {
upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, len(m.sites))) upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
} else { } else {
upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, len(m.sites))) upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
} }
statusParts := []string{upStr} statusParts := []string{upStr}
if len(m.nodes) > 0 { if len(m.nodes) > 0 {