diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go index 60167bc..d16723b 100644 --- a/internal/metrics/prometheus_test.go +++ b/internal/metrics/prometheus_test.go @@ -62,6 +62,8 @@ func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { retur func (m *mockStore) EndMaintenanceWindow(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) GetPreference(string) (string, error) { return "", nil } +func (m *mockStore) SetPreference(string, string) error { return nil } func TestMetricsHandler(t *testing.T) { ms := &mockStore{ diff --git a/internal/store/postgres.go b/internal/store/postgres.go index f2a59e8..f119a37 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -67,6 +67,10 @@ func (d *PostgresDialect) CreateTablesSQL() []string { created_by TEXT DEFAULT '', created_at TIMESTAMP DEFAULT NOW() )`, + `CREATE TABLE IF NOT EXISTS preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, } } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 30fd07e..5cab498 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -67,6 +67,10 @@ func (d *SQLiteDialect) CreateTablesSQL() []string { created_by TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, + `CREATE TABLE IF NOT EXISTS preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, } } diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 7db980c..ebb0aa1 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -441,6 +441,24 @@ func (s *SQLStore) IsMonitorInMaintenance(monitorID int) (bool, error) { 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) { sites, err := s.GetSites() if err != nil { diff --git a/internal/store/store.go b/internal/store/store.go index 3c59e0c..d83ca72 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -57,6 +57,10 @@ type Store interface { DeleteMaintenanceWindow(id int) error IsMonitorInMaintenance(monitorID int) (bool, error) + // Preferences + GetPreference(key string) (string, error) + SetPreference(key, value string) error + // Backup & Restore ExportData() (models.Backup, error) ImportData(data models.Backup) error diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 6a978bd..ac89ce1 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -29,9 +29,9 @@ func typeIcon(siteType string, collapsed bool) string { return "◆" case "group": if collapsed { - return "" + return "▶" } - return "" + return "▼" default: return "·" } @@ -132,6 +132,93 @@ func heartbeatSparkline(statuses []bool, width int) 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 { ms := d.Milliseconds() if ms == 0 { @@ -227,7 +314,7 @@ func fmtStatus(status string, paused bool, inMaint bool) string { func (m Model) dynamicWidths() (nameW, sparkW int) { fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY overhead := 30 // cell padding + borders - avail := m.termWidth - 6 - fixed - overhead + avail := m.termWidth - chromePadH - 2 - fixed - overhead if avail < 30 { avail = 30 } @@ -285,8 +372,8 @@ func (m Model) viewSitesTab() string { "group", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), subtleStyle.Render("—"), - subtleStyle.Render("—"), - subtleStyle.Render(strings.Repeat("·", sparkWidth)), + m.groupUptime(site.ID), + m.groupSparkline(site.ID, sparkWidth), subtleStyle.Render("-"), subtleStyle.Render("—"), }) @@ -619,8 +706,19 @@ func (m Model) viewDetailPanel() string { var b strings.Builder - title := titleStyle.Render(fmt.Sprintf(" %s", site.Name)) - b.WriteString(title + "\n\n") + var breadcrumb string + 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) { b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value)) @@ -690,8 +788,37 @@ func (m Model) viewDetailPanel() string { const sparkWidth = 40 if site.Type == "push" { 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 { 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") diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index be8719e..7c4e654 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -21,6 +21,10 @@ var ( tableBorderStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#444")) + + tableZebraStyle = lipgloss.NewStyle(). + Padding(0, 1). + Background(lipgloss.Color("#1a1a2e")) ) 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 rows := buildRows(m.tableOffset, end) - tableWidth := m.termWidth - 6 + tableWidth := m.termWidth - chromePadH - 2 if tableWidth < 40 { tableWidth = 40 } @@ -53,16 +57,24 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en if row == table.HeaderRow { return tableHeaderStyle } + isSelected := row == selectedVisual if styleOverride != nil { if s := styleOverride(row, col); s != nil { - if col < len(colWidths) && colWidths[col] > 0 { - return s.Width(colWidths[col]) + style := *s + 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 - if row == selectedVisual { + if row%2 == 1 { + base = tableZebraStyle + } + if isSelected { base = tableSelectedStyle } if col < len(colWidths) && colWidths[col] > 0 { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1a2e9b6..a992ca6 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,6 +1,7 @@ package tui import ( + "encoding/json" "fmt" "go-upkeep/internal/models" "go-upkeep/internal/monitor" @@ -31,6 +32,16 @@ var ( 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 const ( @@ -95,6 +106,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model { vpLogs.SetContent("Waiting for logs...") z := zone.New() spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4) + collapsed := loadCollapsed(s) return Model{ state: stateDashboard, logViewport: vpLogs, @@ -104,10 +116,37 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model { engine: eng, zones: z, 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 { 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: m.termWidth = msg.Width 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 := 10 - if m.filterText != "" { + chrome := chromeBase + if m.filterMode || m.filterText != "" { chrome++ } m.maxTableRows = msg.Height - chrome if m.maxTableRows < 1 { m.maxTableRows = 1 } - m.logViewport.Width = msg.Width - 4 - m.logViewport.Height = msg.Height - 8 + m.logViewport.Width = msg.Width - chromePadH + m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter) return m, tea.ClearScreen 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" { gid := m.sites[m.cursor].ID m.collapsed[gid] = !m.collapsed[gid] + saveCollapsed(m.store, m.collapsed) m.refreshData() } case "p": @@ -725,8 +764,14 @@ func (m Model) View() string { } func (m Model) viewDashboard() string { + allSites := m.engine.GetAllSites() + totalMonitors := 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") { downCount++ } @@ -741,8 +786,8 @@ func (m Model) viewDashboard() string { var sitesLabel string if downCount > 0 { sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount) - } else if len(m.sites) > 0 { - sitesLabel = fmt.Sprintf("Sites (%d)", len(m.sites)) + } else if totalMonitors > 0 { + sitesLabel = fmt.Sprintf("Sites (%d)", totalMonitors) } else { sitesLabel = "Sites" } @@ -806,12 +851,12 @@ func (m Model) viewDashboard() string { } } - upCount := len(m.sites) - downCount + upCount := totalMonitors - downCount var upStr string 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 { - 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} if len(m.nodes) > 0 {