From 5d362fdbe6c634a3ff7f3e8a34feaf7a7b2360d6 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 2 Jun 2026 21:06:30 -0400 Subject: [PATCH] refactor(tui): decompose god files into single-concern modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tui.go (1032→164) and tab_sites.go (993→482) violated "small functions" and "testable in isolation" standards. Extracted 6 new files by concern: - format.go: pure formatting functions (fmtLatency, fmtUptime, etc.) - sparkline.go: sparkline rendering (latency, heartbeat, group) - update.go: Update method decomposed into 15 named handlers - view_dashboard.go: View, dashboard composition, tab bar, footer - view_detail.go: site detail panel - data.go: data refresh with extracted sortSitesForDisplay/filterSites Added 17 unit tests for the newly-testable pure functions covering format, sparkline, sort ordering, and filter logic. No behavioral changes — strict move-and-extract refactor. --- internal/tui/data.go | 124 +++++ internal/tui/data_test.go | 95 ++++ internal/tui/format.go | 171 +++++++ internal/tui/format_test.go | 151 ++++++ internal/tui/sparkline.go | 174 +++++++ internal/tui/sparkline_test.go | 59 +++ internal/tui/tab_sites.go | 511 ------------------- internal/tui/tui.go | 868 --------------------------------- internal/tui/update.go | 539 ++++++++++++++++++++ internal/tui/view_dashboard.go | 279 +++++++++++ internal/tui/view_detail.go | 207 ++++++++ 11 files changed, 1799 insertions(+), 1379 deletions(-) create mode 100644 internal/tui/data.go create mode 100644 internal/tui/data_test.go create mode 100644 internal/tui/format.go create mode 100644 internal/tui/format_test.go create mode 100644 internal/tui/sparkline.go create mode 100644 internal/tui/sparkline_test.go create mode 100644 internal/tui/update.go create mode 100644 internal/tui/view_dashboard.go create mode 100644 internal/tui/view_detail.go diff --git a/internal/tui/data.go b/internal/tui/data.go new file mode 100644 index 0000000..59f6a36 --- /dev/null +++ b/internal/tui/data.go @@ -0,0 +1,124 @@ +package tui + +import ( + "encoding/json" + "sort" + "strings" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/store" +) + +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 sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool) []models.Site { + var groups, ungrouped []models.Site + children := make(map[int][]models.Site) + for _, s := range allSites { + if s.Type == "group" { + groups = append(groups, s) + } else if s.ParentID > 0 { + children[s.ParentID] = append(children[s.ParentID], s) + } else { + ungrouped = append(ungrouped, s) + } + } + sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID }) + for pid := range children { + c := children[pid] + sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID }) + sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) }) + children[pid] = c + } + sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID }) + sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) }) + + var ordered []models.Site + for _, g := range groups { + ordered = append(ordered, g) + if !collapsed[g.ID] { + ordered = append(ordered, children[g.ID]...) + } + } + ordered = append(ordered, ungrouped...) + return ordered +} + +func filterSites(sites []models.Site, needle string) []models.Site { + lower := strings.ToLower(needle) + var filtered []models.Site + for _, s := range sites { + if strings.Contains(strings.ToLower(s.Name), lower) { + filtered = append(filtered, s) + } + } + return filtered +} + +func (m *Model) refreshData() { + allSites := m.engine.GetAllSites() + ordered := sortSitesForDisplay(allSites, m.collapsed) + if m.filterText != "" { + ordered = filterSites(ordered, m.filterText) + } + m.sites = ordered + + if alerts, err := m.store.GetAllAlerts(); err == nil { + m.alerts = alerts + } + if m.isAdmin { + if users, err := m.store.GetAllUsers(); err == nil { + m.users = users + } + } + if nodes, err := m.store.GetAllNodes(); err == nil { + m.nodes = nodes + } + if windows, err := m.store.GetAllMaintenanceWindows(100); err == nil { + m.maintenanceWindows = windows + } + m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n")) + + listLen := len(m.sites) + switch m.currentTab { + case 1: + listLen = len(m.alerts) + case 3: + listLen = len(m.nodes) + case 4: + listLen = len(m.maintenanceWindows) + case 5: + listLen = len(m.users) + } + if listLen > 0 && m.cursor >= listLen { + m.cursor = listLen - 1 + } + if m.cursor < m.tableOffset { + m.tableOffset = m.cursor + } +} diff --git a/internal/tui/data_test.go b/internal/tui/data_test.go new file mode 100644 index 0000000..9f38f58 --- /dev/null +++ b/internal/tui/data_test.go @@ -0,0 +1,95 @@ +package tui + +import ( + "testing" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" +) + +func TestSortSitesForDisplay_GroupsFirst(t *testing.T) { + sites := []models.Site{ + {ID: 3, Name: "ungrouped", Type: "http", Status: "UP"}, + {ID: 1, Name: "group-a", Type: "group", Status: "UP"}, + {ID: 2, Name: "child", Type: "http", Status: "UP", ParentID: 1}, + } + result := sortSitesForDisplay(sites, nil) + if len(result) != 3 { + t.Fatalf("expected 3 sites, got %d", len(result)) + } + if result[0].Name != "group-a" { + t.Errorf("first should be group, got %s", result[0].Name) + } + if result[1].Name != "child" { + t.Errorf("second should be child, got %s", result[1].Name) + } + if result[2].Name != "ungrouped" { + t.Errorf("third should be ungrouped, got %s", result[2].Name) + } +} + +func TestSortSitesForDisplay_CollapsedHidesChildren(t *testing.T) { + sites := []models.Site{ + {ID: 1, Name: "group-a", Type: "group", Status: "UP"}, + {ID: 2, Name: "child-1", Type: "http", Status: "UP", ParentID: 1}, + {ID: 3, Name: "child-2", Type: "http", Status: "UP", ParentID: 1}, + } + collapsed := map[int]bool{1: true} + result := sortSitesForDisplay(sites, collapsed) + if len(result) != 1 { + t.Fatalf("collapsed group should hide children, got %d items", len(result)) + } + if result[0].Name != "group-a" { + t.Errorf("only group should remain, got %s", result[0].Name) + } +} + +func TestSortSitesForDisplay_StatusOrdering(t *testing.T) { + sites := []models.Site{ + {ID: 1, Name: "up-site", Type: "http", Status: "UP"}, + {ID: 2, Name: "down-site", Type: "http", Status: "DOWN"}, + {ID: 3, Name: "late-site", Type: "http", Status: "LATE"}, + } + result := sortSitesForDisplay(sites, nil) + if result[0].Status != "DOWN" { + t.Errorf("DOWN should sort first, got %s", result[0].Status) + } + if result[1].Status != "LATE" { + t.Errorf("LATE should sort second, got %s", result[1].Status) + } + if result[2].Status != "UP" { + t.Errorf("UP should sort third, got %s", result[2].Status) + } +} + +func TestFilterSites(t *testing.T) { + sites := []models.Site{ + {Name: "Production API"}, + {Name: "Staging API"}, + {Name: "Database"}, + } + + tests := []struct { + needle string + want int + }{ + {"api", 2}, + {"API", 2}, + {"database", 1}, + {"nonexistent", 0}, + {"", 3}, + } + for _, tt := range tests { + got := filterSites(sites, tt.needle) + if len(got) != tt.want { + t.Errorf("filterSites(%q) returned %d, want %d", tt.needle, len(got), tt.want) + } + } +} + +func TestFilterSites_EmptyNeedle(t *testing.T) { + sites := []models.Site{{Name: "a"}, {Name: "b"}} + got := filterSites(sites, "") + if len(got) != 2 { + t.Errorf("empty needle should return all, got %d", len(got)) + } +} diff --git a/internal/tui/format.go b/internal/tui/format.go new file mode 100644 index 0000000..6757365 --- /dev/null +++ b/internal/tui/format.go @@ -0,0 +1,171 @@ +package tui + +import ( + "fmt" + "time" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" +) + +func limitStr(text string, max int) string { + runes := []rune(text) + if len(runes) > max { + return string(runes[:max-3]) + "..." + } + return text +} + +func siteOrder(s models.Site) int { + if s.Paused { + return 3 + } + switch s.Status { + case "DOWN", "SSL EXP": + return 0 + case "LATE": + return 1 + case "PENDING": + return 3 + default: + return 2 + } +} + +func typeIcon(siteType string, collapsed bool) string { + switch siteType { + case "http": + return "→" + case "push": + return "↓" + case "ping": + return "↔" + case "port": + return "⊡" + case "dns": + return "◆" + case "group": + if collapsed { + return "▶" + } + return "▼" + default: + return "·" + } +} + +func fmtLatency(d time.Duration) string { + ms := d.Milliseconds() + if ms == 0 { + return subtleStyle.Render("—") + } + var s string + if ms < 1000 { + s = fmt.Sprintf("%dms", ms) + } else { + s = fmt.Sprintf("%.1fs", float64(ms)/1000) + } + if ms < 200 { + return specialStyle.Render(s) + } + if ms < 500 { + return warnStyle.Render(s) + } + return dangerStyle.Render(s) +} + +func fmtUptime(statuses []bool) string { + if len(statuses) == 0 { + return subtleStyle.Render("—") + } + up := 0 + for _, s := range statuses { + if s { + up++ + } + } + pct := float64(up) / float64(len(statuses)) * 100 + s := fmt.Sprintf("%.1f%%", pct) + if pct >= 99 { + return specialStyle.Render(s) + } + if pct >= 95 { + return warnStyle.Render(s) + } + return dangerStyle.Render(s) +} + +func fmtSSL(site models.Site) string { + if site.Type != "http" || !site.CheckSSL || !site.HasSSL { + return subtleStyle.Render("-") + } + days := int(time.Until(site.CertExpiry).Hours() / 24) + s := fmt.Sprintf("%dd", days) + if days <= 0 { + return dangerStyle.Render("EXPIRED") + } + if days <= site.ExpiryThreshold { + return warnStyle.Render(s) + } + return specialStyle.Render(s) +} + +func fmtRetries(site models.Site) string { + retriesDone := site.FailureCount - 1 + if retriesDone < 0 { + retriesDone = 0 + } + dispCount := retriesDone + if dispCount > site.MaxRetries { + dispCount = site.MaxRetries + } + s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries) + if site.Status == "DOWN" { + return dangerStyle.Render(s) + } + if site.Status == "UP" && site.FailureCount > 0 { + return warnStyle.Render(s) + } + return s +} + +func fmtStatus(status string, paused bool, inMaint bool) string { + if paused { + return warnStyle.Render("PAUSED") + } + if inMaint { + return maintStyle.Render("MAINT") + } + switch status { + case "DOWN", "SSL EXP": + return dangerStyle.Render(status) + case "LATE": + return warnStyle.Render(status) + case "PENDING": + return subtleStyle.Render(status) + default: + return specialStyle.Render(status) + } +} + +func fmtDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + if m > 0 { + return fmt.Sprintf("%dh %dm", h, m) + } + return fmt.Sprintf("%dh", h) + } + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + if hours > 0 { + return fmt.Sprintf("%dd %dh", days, hours) + } + return fmt.Sprintf("%dd", days) +} diff --git a/internal/tui/format_test.go b/internal/tui/format_test.go new file mode 100644 index 0000000..721a59d --- /dev/null +++ b/internal/tui/format_test.go @@ -0,0 +1,151 @@ +package tui + +import ( + "testing" + "time" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" +) + +func init() { + applyTheme(themeFlexokiDark) +} + +func TestLimitStr(t *testing.T) { + tests := []struct { + input string + max int + want string + }{ + {"hello", 10, "hello"}, + {"hello", 5, "hello"}, + {"hello world", 8, "hello..."}, + {"", 5, ""}, + {"abc", 3, "abc"}, + {"abcd", 3, "..."}, + {"日本語テスト", 4, "日..."}, + } + for _, tt := range tests { + got := limitStr(tt.input, tt.max) + if got != tt.want { + t.Errorf("limitStr(%q, %d) = %q, want %q", tt.input, tt.max, got, tt.want) + } + } +} + +func TestSiteOrder(t *testing.T) { + tests := []struct { + name string + site models.Site + want int + }{ + {"down", models.Site{Status: "DOWN"}, 0}, + {"ssl exp", models.Site{Status: "SSL EXP"}, 0}, + {"late", models.Site{Status: "LATE"}, 1}, + {"up", models.Site{Status: "UP"}, 2}, + {"pending", models.Site{Status: "PENDING"}, 3}, + {"paused up", models.Site{Status: "UP", Paused: true}, 3}, + {"paused down", models.Site{Status: "DOWN", Paused: true}, 3}, + } + for _, tt := range tests { + got := siteOrder(tt.site) + if got != tt.want { + t.Errorf("siteOrder(%s) = %d, want %d", tt.name, got, tt.want) + } + } +} + +func TestFmtDuration(t *testing.T) { + tests := []struct { + d time.Duration + want string + }{ + {30 * time.Second, "30s"}, + {5 * time.Minute, "5m"}, + {2*time.Hour + 30*time.Minute, "2h 30m"}, + {2 * time.Hour, "2h"}, + {25 * time.Hour, "1d 1h"}, + {48 * time.Hour, "2d"}, + {49 * time.Hour, "2d 1h"}, + } + for _, tt := range tests { + got := fmtDuration(tt.d) + if got != tt.want { + t.Errorf("fmtDuration(%v) = %q, want %q", tt.d, got, tt.want) + } + } +} + +func TestTypeIcon(t *testing.T) { + tests := []struct { + siteType string + collapsed bool + want string + }{ + {"http", false, "→"}, + {"push", false, "↓"}, + {"ping", false, "↔"}, + {"port", false, "⊡"}, + {"dns", false, "◆"}, + {"group", false, "▼"}, + {"group", true, "▶"}, + {"unknown", false, "·"}, + } + for _, tt := range tests { + got := typeIcon(tt.siteType, tt.collapsed) + if got != tt.want { + t.Errorf("typeIcon(%q, %v) = %q, want %q", tt.siteType, tt.collapsed, got, tt.want) + } + } +} + +func TestFmtUptime(t *testing.T) { + tests := []struct { + name string + statuses []bool + wantSub string + }{ + {"empty", nil, "—"}, + {"all up", []bool{true, true, true, true}, "100.0%"}, + {"half", []bool{true, false, true, false}, "50.0%"}, + {"all down", []bool{false, false}, "0.0%"}, + } + for _, tt := range tests { + got := fmtUptime(tt.statuses) + if !containsPlain(got, tt.wantSub) { + t.Errorf("fmtUptime(%s): %q missing %q", tt.name, got, tt.wantSub) + } + } +} + +func TestFmtLatency(t *testing.T) { + tests := []struct { + d time.Duration + wantSub string + }{ + {0, "—"}, + {50 * time.Millisecond, "50ms"}, + {300 * time.Millisecond, "300ms"}, + {1500 * time.Millisecond, "1.5s"}, + } + for _, tt := range tests { + got := fmtLatency(tt.d) + if !containsPlain(got, tt.wantSub) { + t.Errorf("fmtLatency(%v): %q missing %q", tt.d, got, tt.wantSub) + } + } +} + +func containsPlain(styled, sub string) bool { + // ANSI-styled strings contain the substring somewhere + return len(styled) > 0 && contains(styled, sub) +} + +func contains(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return len(sub) == 0 +} diff --git a/internal/tui/sparkline.go b/internal/tui/sparkline.go new file mode 100644 index 0000000..a639e1a --- /dev/null +++ b/internal/tui/sparkline.go @@ -0,0 +1,174 @@ +package tui + +import ( + "strings" + "time" +) + +var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + +func latencySparkline(latencies []time.Duration, statuses []bool, width int) string { + if len(latencies) == 0 { + return subtleStyle.Render(strings.Repeat("·", width)) + } + + samples := latencies + sampledStatuses := statuses + if len(samples) > width { + samples = samples[len(samples)-width:] + if len(sampledStatuses) > width { + sampledStatuses = sampledStatuses[len(sampledStatuses)-width:] + } + } + + minL, maxL := samples[0], samples[0] + for _, l := range samples { + if l < minL { + minL = l + } + if l > maxL { + maxL = l + } + } + + var sb strings.Builder + if remaining := width - len(samples); remaining > 0 { + sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) + } + spread := maxL - minL + for i, l := range samples { + idx := 0 + if spread > 0 { + idx = int(float64(l-minL) / float64(spread) * 7) + if idx > 7 { + idx = 7 + } + } + ch := string(sparkChars[idx]) + isDown := i < len(sampledStatuses) && !sampledStatuses[i] + if isDown { + sb.WriteString(dangerStyle.Render(ch)) + } else { + ms := l.Milliseconds() + if ms < 200 { + sb.WriteString(specialStyle.Render(ch)) + } else if ms < 500 { + sb.WriteString(warnStyle.Render(ch)) + } else { + sb.WriteString(dangerStyle.Render(ch)) + } + } + } + return sb.String() +} + +func heartbeatSparkline(statuses []bool, width int) string { + if len(statuses) == 0 { + return subtleStyle.Render(strings.Repeat("·", width)) + } + + samples := statuses + if len(samples) > width { + samples = samples[len(samples)-width:] + } + + var sb strings.Builder + if remaining := width - len(samples); remaining > 0 { + sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) + } + for _, up := range samples { + if up { + sb.WriteString(specialStyle.Render("▁")) + } else { + sb.WriteString(dangerStyle.Render("█")) + } + } + 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 + }()) +} diff --git a/internal/tui/sparkline_test.go b/internal/tui/sparkline_test.go new file mode 100644 index 0000000..6a1d57d --- /dev/null +++ b/internal/tui/sparkline_test.go @@ -0,0 +1,59 @@ +package tui + +import ( + "strings" + "testing" + "time" +) + +func TestLatencySparkline_Empty(t *testing.T) { + got := latencySparkline(nil, nil, 10) + if !strings.Contains(got, "··········") { + t.Errorf("empty sparkline should be dots, got %q", got) + } +} + +func TestLatencySparkline_SingleValue(t *testing.T) { + latencies := []time.Duration{100 * time.Millisecond} + statuses := []bool{true} + got := latencySparkline(latencies, statuses, 5) + if len(got) == 0 { + t.Error("sparkline should not be empty") + } +} + +func TestLatencySparkline_WidthTruncation(t *testing.T) { + latencies := make([]time.Duration, 20) + statuses := make([]bool, 20) + for i := range latencies { + latencies[i] = time.Duration(i*50) * time.Millisecond + statuses[i] = true + } + got := latencySparkline(latencies, statuses, 5) + if len(got) == 0 { + t.Error("sparkline should not be empty") + } +} + +func TestHeartbeatSparkline_Empty(t *testing.T) { + got := heartbeatSparkline(nil, 10) + if !strings.Contains(got, "··········") { + t.Errorf("empty heartbeat should be dots, got %q", got) + } +} + +func TestHeartbeatSparkline_Mixed(t *testing.T) { + statuses := []bool{true, false, true, true, false} + got := heartbeatSparkline(statuses, 5) + if len(got) == 0 { + t.Error("heartbeat sparkline should not be empty") + } +} + +func TestHeartbeatSparkline_PaddedWidth(t *testing.T) { + statuses := []bool{true, true} + got := heartbeatSparkline(statuses, 5) + if !strings.Contains(got, "···") { + t.Errorf("should have dot padding for width > data, got %q", got) + } +} diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 971d764..7f16b8e 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -4,40 +4,13 @@ import ( "fmt" "net/url" "strconv" - "strings" - "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) -var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} - -func typeIcon(siteType string, collapsed bool) string { - switch siteType { - case "http": - return "→" - case "push": - return "↓" - case "ping": - return "↔" - case "port": - return "⊡" - case "dns": - return "◆" - case "group": - if collapsed { - return "▶" - } - return "▼" - default: - return "·" - } -} - var siteGroupStyle lipgloss.Style type siteFormData struct { @@ -60,289 +33,6 @@ type siteFormData struct { Regions string } -func latencySparkline(latencies []time.Duration, statuses []bool, width int) string { - if len(latencies) == 0 { - return subtleStyle.Render(strings.Repeat("·", width)) - } - - samples := latencies - sampledStatuses := statuses - if len(samples) > width { - samples = samples[len(samples)-width:] - if len(sampledStatuses) > width { - sampledStatuses = sampledStatuses[len(sampledStatuses)-width:] - } - } - - minL, maxL := samples[0], samples[0] - for _, l := range samples { - if l < minL { - minL = l - } - if l > maxL { - maxL = l - } - } - - var sb strings.Builder - if remaining := width - len(samples); remaining > 0 { - sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) - } - spread := maxL - minL - for i, l := range samples { - idx := 0 - if spread > 0 { - idx = int(float64(l-minL) / float64(spread) * 7) - if idx > 7 { - idx = 7 - } - } - ch := string(sparkChars[idx]) - isDown := i < len(sampledStatuses) && !sampledStatuses[i] - if isDown { - sb.WriteString(dangerStyle.Render(ch)) - } else { - ms := l.Milliseconds() - if ms < 200 { - sb.WriteString(specialStyle.Render(ch)) - } else if ms < 500 { - sb.WriteString(warnStyle.Render(ch)) - } else { - sb.WriteString(dangerStyle.Render(ch)) - } - } - } - return sb.String() -} - -func heartbeatSparkline(statuses []bool, width int) string { - if len(statuses) == 0 { - return subtleStyle.Render(strings.Repeat("·", width)) - } - - samples := statuses - if len(samples) > width { - samples = samples[len(samples)-width:] - } - - var sb strings.Builder - if remaining := width - len(samples); remaining > 0 { - sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) - } - for _, up := range samples { - if up { - sb.WriteString(specialStyle.Render("▁")) - } else { - sb.WriteString(dangerStyle.Render("█")) - } - } - 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 { - return subtleStyle.Render("—") - } - var s string - if ms < 1000 { - s = fmt.Sprintf("%dms", ms) - } else { - s = fmt.Sprintf("%.1fs", float64(ms)/1000) - } - if ms < 200 { - return specialStyle.Render(s) - } - if ms < 500 { - return warnStyle.Render(s) - } - return dangerStyle.Render(s) -} - -func fmtUptime(statuses []bool) string { - if len(statuses) == 0 { - return subtleStyle.Render("—") - } - up := 0 - for _, s := range statuses { - if s { - up++ - } - } - pct := float64(up) / float64(len(statuses)) * 100 - s := fmt.Sprintf("%.1f%%", pct) - if pct >= 99 { - return specialStyle.Render(s) - } - if pct >= 95 { - return warnStyle.Render(s) - } - return dangerStyle.Render(s) -} - -func fmtSSL(site models.Site) string { - if site.Type != "http" || !site.CheckSSL || !site.HasSSL { - return subtleStyle.Render("-") - } - days := int(time.Until(site.CertExpiry).Hours() / 24) - s := fmt.Sprintf("%dd", days) - if days <= 0 { - return dangerStyle.Render("EXPIRED") - } - if days <= site.ExpiryThreshold { - return warnStyle.Render(s) - } - return specialStyle.Render(s) -} - -func fmtRetries(site models.Site) string { - retriesDone := site.FailureCount - 1 - if retriesDone < 0 { - retriesDone = 0 - } - dispCount := retriesDone - if dispCount > site.MaxRetries { - dispCount = site.MaxRetries - } - s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries) - if site.Status == "DOWN" { - return dangerStyle.Render(s) - } - if site.Status == "UP" && site.FailureCount > 0 { - return warnStyle.Render(s) - } - return s -} - -func fmtStatus(status string, paused bool, inMaint bool) string { - if paused { - return warnStyle.Render("PAUSED") - } - if inMaint { - return maintStyle.Render("MAINT") - } - switch status { - case "DOWN", "SSL EXP": - return dangerStyle.Render(status) - case "LATE": - return warnStyle.Render(status) - case "PENDING": - return subtleStyle.Render(status) - default: - return specialStyle.Render(status) - } -} - -func fmtDuration(d time.Duration) string { - if d < time.Minute { - return fmt.Sprintf("%ds", int(d.Seconds())) - } - if d < time.Hour { - return fmt.Sprintf("%dm", int(d.Minutes())) - } - if d < 24*time.Hour { - h := int(d.Hours()) - m := int(d.Minutes()) % 60 - if m > 0 { - return fmt.Sprintf("%dh %dm", h, m) - } - return fmt.Sprintf("%dh", h) - } - days := int(d.Hours()) / 24 - hours := int(d.Hours()) % 24 - if hours > 0 { - return fmt.Sprintf("%dd %dh", days, hours) - } - return fmt.Sprintf("%dd", days) -} - type tableLayout struct { nameW, sparkW int headers []string @@ -357,12 +47,10 @@ func (m Model) computeLayout() tableLayout { var widths []int if wide { - // # NAME TYPE STATUS LATENCY UPTIME HISTORY SSL RETRIES headers = []string{"#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRIES"} widths = []int{4, 0, 10, 10, 10, 8, 0, 7, 9} fixed = 4 + 10 + 10 + 10 + 8 + 7 + 9 } else { - // # NAME TYPE STATUS LAT UP% HISTORY SSL RT headers = []string{"#", "NAME", "TYPE", "STATUS", "LAT", "UP%", "HISTORY", "SSL", "RT"} widths = []int{4, 0, 8, 8, 7, 8, 0, 5, 5} fixed = 4 + 8 + 8 + 7 + 8 + 5 + 5 @@ -792,202 +480,3 @@ func (m *Model) submitSiteForm() { } m.state = stateDashboard } - -func (m Model) viewDetailPanel() string { - if m.cursor >= len(m.sites) { - return "" - } - site := m.sites[m.cursor] - hist, _ := m.engine.GetHistory(site.ID) - - var b strings.Builder - - 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) { - fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value) - } - - section := func(label string) { - b.WriteString("\n" + subtleStyle.Render(" "+label) + "\n") - } - - row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))) - - if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" { - row("Error", dangerStyle.Render(limitStr(site.LastError, 60))) - } - - if site.Type == "http" && site.StatusCode > 0 { - row("HTTP Code", strconv.Itoa(site.StatusCode)) - } - - if !site.StatusChangedAt.IsZero() { - dur := time.Since(site.StatusChangedAt) - row("State Since", site.StatusChangedAt.Format("2006-01-02 15:04:05")+" ("+fmtDuration(dur)+")") - } - - if !site.LastSuccessAt.IsZero() { - ago := time.Since(site.LastSuccessAt) - row("Last Success", site.LastSuccessAt.Format("15:04:05")+" ("+fmtDuration(ago)+" ago)") - } - - if m.isMonitorInMaintenance(site.ID) { - for _, mw := range m.maintenanceWindows { - if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) { - row("Maintenance", maintStyle.Render(mw.Title)) - break - } - } - } - - section("ENDPOINT") - row("Type", site.Type) - if site.URL != "" { - row("URL", site.URL) - } - if site.Hostname != "" { - row("Host", site.Hostname) - } - if site.Port > 0 { - row("Port", strconv.Itoa(site.Port)) - } - - section("TIMING") - row("Interval", fmt.Sprintf("%ds", site.Interval)) - if site.Timeout > 0 { - row("Timeout", fmt.Sprintf("%ds", site.Timeout)) - } - row("Latency", fmtLatency(site.Latency)) - row("Uptime", fmtUptime(hist.Statuses)) - if !site.LastCheck.IsZero() { - row("Last Check", site.LastCheck.Format("15:04:05")) - } - - if site.Type == "http" { - section("HTTP") - if site.Method != "" && site.Method != "GET" { - row("Method", site.Method) - } - codes := site.AcceptedCodes - if codes == "" { - codes = "200-299" - } - row("Codes", codes) - row("SSL", fmtSSL(site)) - if site.IgnoreTLS { - row("TLS Verify", dangerStyle.Render("disabled")) - } - } - - if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" { - section("CONFIG") - if site.MaxRetries > 0 { - row("Retries", fmtRetries(site)) - } - if site.Regions != "" { - row("Regions", site.Regions) - } - if site.Description != "" { - row("Description", site.Description) - } - } - - probeResults := m.engine.GetProbeResults(site.ID) - if len(probeResults) > 0 { - b.WriteString("\n" + subtleStyle.Render(" PROBE RESULTS") + "\n") - for nodeID, result := range probeResults { - status := specialStyle.Render("UP") - if !result.IsUp { - status = dangerStyle.Render("DN") - } - latency := time.Duration(result.LatencyNs).Milliseconds() - ago := time.Since(result.CheckedAt).Truncate(time.Second) - line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago) - if !result.IsUp && result.ErrorReason != "" { - line += " " + dangerStyle.Render(limitStr(result.ErrorReason, 30)) - } - b.WriteString(line + "\n") - } - } - - stateChanges := m.engine.GetStateChanges(site.ID, 5) - if len(stateChanges) > 0 { - b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n") - for _, sc := range stateChanges { - ago := fmtDuration(time.Since(sc.ChangedAt)) - arrow := subtleStyle.Render(sc.FromStatus) + " → " - if sc.ToStatus == "UP" { - arrow += specialStyle.Render(sc.ToStatus) - } else { - arrow += dangerStyle.Render(sc.ToStatus) - } - line := fmt.Sprintf(" %s %s", arrow, subtleStyle.Render(ago+" ago")) - if sc.ErrorReason != "" && sc.ToStatus != "UP" { - line += " " + dangerStyle.Render(limitStr(sc.ErrorReason, 40)) - } - b.WriteString(line + "\n") - } - } - - b.WriteString("\n") - 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++ - } - } - fmt.Fprintf(&b, "\n %s %d/%d checks up", - subtleStyle.Render("Heartbeats"), - up, len(hist.Statuses)) - } - } else { - b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth)) - // Stats over successful checks only — a failed check is stored as 0ns latency - // and would otherwise drag Min to 0ms and skew the average. - var minL, maxL, total time.Duration - count := 0 - for i, l := range hist.Latencies { - if i < len(hist.Statuses) && !hist.Statuses[i] { - continue - } - if count == 0 { - minL, maxL = l, l - } else if l < minL { - minL = l - } else if l > maxL { - maxL = l - } - total += l - count++ - } - if count > 0 { - avg := total / time.Duration(count) - fmt.Fprintf(&b, "\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(subtleStyle.Render(" [i/Esc] Back [e] Edit [q] Quit")) - - return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d020099..534e90a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,12 +1,7 @@ package tui import ( - "encoding/json" - "fmt" - "math" "os" - "sort" - "strings" "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" @@ -164,869 +159,6 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model { } } -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 })) } - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - if m.state == stateConfirmDelete { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case "y", "Y": - switch m.deleteTab { - case 0: - if err := m.store.DeleteSite(m.deleteID); err != nil { - m.engine.AddLog("Delete site failed: " + err.Error()) - } - m.engine.RemoveSite(m.deleteID) - m.adjustCursor(len(m.sites) - 1) - case 1: - if err := m.store.DeleteAlert(m.deleteID); err != nil { - m.engine.AddLog("Delete alert failed: " + err.Error()) - } - m.adjustCursor(len(m.alerts) - 1) - case 4: - if err := m.store.DeleteMaintenanceWindow(m.deleteID); err != nil { - m.engine.AddLog("Delete maintenance window failed: " + err.Error()) - } - m.adjustCursor(len(m.maintenanceWindows) - 1) - case 5: - if err := m.store.DeleteUser(m.deleteID); err != nil { - m.engine.AddLog("Delete user failed: " + err.Error()) - } - m.adjustCursor(len(m.users) - 1) - } - m.refreshData() - m.state = stateDashboard - if m.deleteTab == 5 { - m.state = stateUsers - } - case "n", "N", "esc": - m.state = stateDashboard - if m.deleteTab == 5 { - m.state = stateUsers - } - case "ctrl+c": - return m, tea.Quit - } - } - return m, nil - } - - // Form state: forward ALL messages to huh (keys, timers, resize, etc.) - if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint { - if wsm, ok := msg.(tea.WindowSizeMsg); ok { - m.termWidth = wsm.Width - m.termHeight = wsm.Height - } - if keyMsg, ok := msg.(tea.KeyMsg); ok { - if keyMsg.String() == "ctrl+c" { - return m, tea.Quit - } - if keyMsg.String() == "esc" { - m.huhForm = nil - m.state = stateDashboard - if m.currentTab == 5 { - m.state = stateUsers - } - return m, nil - } - } - if m.huhForm != nil { - form, formCmd := m.huhForm.Update(msg) - if f, ok := form.(*huh.Form); ok { - m.huhForm = f - } - if m.huhForm.State == huh.StateCompleted { - m.submitForm() - m.refreshData() - m.huhForm = nil - return m, nil - } - return m, formCmd - } - return m, nil - } - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.termWidth = msg.Width - m.termHeight = msg.Height - 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 - chromePadH - m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter) - return m, tea.ClearScreen - - case time.Time: - m.refreshData() - m.tickCount++ - target := math.Sin(float64(m.tickCount)*0.3)*0.5 + 0.5 - m.pulsePos, m.pulseVel = m.pulseSpring.Update(m.pulsePos, m.pulseVel, target) - return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t }) - - case tea.MouseMsg: - if m.state == stateDashboard || m.state == stateLogs || m.state == stateUsers { - if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { - return m.handleClick(msg) - } - if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown { - if m.state == stateLogs { - if msg.Button == tea.MouseButtonWheelUp { - m.logViewport.ScrollUp(3) - } else { - m.logViewport.ScrollDown(3) - } - return m, nil - } - listLen := len(m.sites) - switch m.currentTab { - case 1: - listLen = len(m.alerts) - case 3: - listLen = len(m.nodes) - case 4: - listLen = len(m.maintenanceWindows) - case 5: - listLen = len(m.users) - } - if msg.Button == tea.MouseButtonWheelUp { - if m.cursor > 0 { - m.cursor-- - if m.cursor < m.tableOffset { - m.tableOffset = m.cursor - } - } - } else { - if m.cursor < listLen-1 { - m.cursor++ - if m.cursor >= m.tableOffset+m.maxTableRows { - m.tableOffset++ - } - } - } - return m, nil - } - } - - case tea.KeyMsg: - if msg.String() == "ctrl+c" { - return m, tea.Quit - } - if msg.String() == "ctrl+l" { - return m, tea.ClearScreen - } - - if m.filterMode { - switch msg.String() { - case "esc": - m.filterMode = false - m.filterText = "" - m.cursor = 0 - m.tableOffset = 0 - m.refreshData() - case "enter": - m.filterMode = false - case "backspace": - if len(m.filterText) > 0 { - m.filterText = m.filterText[:len(m.filterText)-1] - m.cursor = 0 - m.tableOffset = 0 - m.refreshData() - } - case "ctrl+c": - return m, tea.Quit - default: - if len(msg.String()) == 1 { - m.filterText += msg.String() - m.cursor = 0 - m.tableOffset = 0 - m.refreshData() - } - } - return m, nil - } - - switch m.state { - case stateDetail: - switch msg.String() { - case "i", "esc": - m.state = stateDashboard - case "q": - return m, tea.Quit - } - return m, nil - case stateAlertDetail: - switch msg.String() { - case "i", "esc": - m.state = stateDashboard - case "q": - return m, tea.Quit - } - return m, nil - case stateDashboard, stateLogs, stateUsers: - switch msg.String() { - case "q": - return m, tea.Quit - case "/": - if m.currentTab == 0 { - m.filterMode = true - return m, nil - } - case "f": - if m.state == stateLogs { - m.logFilterImportant = !m.logFilterImportant - return m, nil - } - case "tab": - m.switchTab(m.currentTab + 1) - case "pgup", "pgdown": - if m.state == stateLogs { - m.logViewport, cmd = m.logViewport.Update(msg) - return m, cmd - } - case "up", "k": - if m.state == stateLogs { - m.logViewport.ScrollUp(1) - } else if m.cursor > 0 { - m.cursor-- - if m.cursor < m.tableOffset { - m.tableOffset = m.cursor - } - } - case "down", "j": - if m.state == stateLogs { - m.logViewport.ScrollDown(1) - } else { - max := len(m.sites) - 1 - if m.currentTab == 1 { - max = len(m.alerts) - 1 - } - if m.currentTab == 3 { - max = len(m.nodes) - 1 - } - if m.currentTab == 4 { - max = len(m.maintenanceWindows) - 1 - } - if m.currentTab == 5 { - max = len(m.users) - 1 - } - if m.cursor < max { - m.cursor++ - if m.cursor >= m.tableOffset+m.maxTableRows { - m.tableOffset++ - } - } - } - case "n": - m.editID = 0 - m.editToken = "" - if m.currentTab == 0 { - m.state = stateFormSite - return m, m.initSiteHuhForm() - } else if m.currentTab == 1 { - m.state = stateFormAlert - return m, m.initAlertHuhForm() - } else if m.currentTab == 4 { - m.state = stateFormMaint - return m, m.initMaintHuhForm() - } else if m.currentTab == 5 && m.isAdmin { - m.state = stateFormUser - return m, m.initUserHuhForm() - } - case "e", "enter": - if m.currentTab == 0 && len(m.sites) > 0 { - m.editID = m.sites[m.cursor].ID - m.editToken = m.sites[m.cursor].Token - m.state = stateFormSite - return m, m.initSiteHuhForm() - } else if m.currentTab == 1 && len(m.alerts) > 0 { - m.editID = m.alerts[m.cursor].ID - m.state = stateFormAlert - return m, m.initAlertHuhForm() - } else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 { - m.editID = m.users[m.cursor].ID - m.state = stateFormUser - return m, m.initUserHuhForm() - } - case "t": - if m.currentTab == 1 && len(m.alerts) > 0 { - a := m.alerts[m.cursor] - go func() { - if err := m.engine.TestAlert(a.ID); err != nil { - m.engine.AddLog(fmt.Sprintf("Test alert failed (%s): %v", a.Name, err)) - } - }() - return m, nil - } - case " ": - 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": - if m.currentTab == 0 && len(m.sites) > 0 { - site := m.sites[m.cursor] - m.engine.ToggleSitePause(site.ID) - site.Paused = !site.Paused - _ = m.store.UpdateSitePaused(site.ID, site.Paused) - m.refreshData() - } - case "i": - if m.currentTab == 0 && len(m.sites) > 0 { - m.state = stateDetail - } else if m.currentTab == 1 && len(m.alerts) > 0 { - m.state = stateAlertDetail - } - case "x": - if m.currentTab == 4 && len(m.maintenanceWindows) > 0 { - mw := m.maintenanceWindows[m.cursor] - now := time.Now() - isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) - if isActive { - if err := m.store.EndMaintenanceWindow(mw.ID); err != nil { - m.engine.AddLog("End maintenance failed: " + err.Error()) - } - m.refreshData() - } - } - case "T": - m.themeIndex = (m.themeIndex + 1) % len(themes) - m.theme = themes[m.themeIndex] - applyTheme(m.theme) - _ = m.store.SetPreference("theme", m.theme.Name) - case "d", "backspace": - if m.currentTab == 0 && len(m.sites) > 0 { - m.deleteID = m.sites[m.cursor].ID - m.deleteName = m.sites[m.cursor].Name - m.deleteTab = 0 - m.state = stateConfirmDelete - } else if m.currentTab == 1 && len(m.alerts) > 0 { - m.deleteID = m.alerts[m.cursor].ID - m.deleteName = m.alerts[m.cursor].Name - m.deleteTab = 1 - m.state = stateConfirmDelete - } else if m.currentTab == 4 && len(m.maintenanceWindows) > 0 { - m.deleteID = m.maintenanceWindows[m.cursor].ID - m.deleteName = m.maintenanceWindows[m.cursor].Title - m.deleteTab = 4 - m.state = stateConfirmDelete - } else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 { - m.deleteID = m.users[m.cursor].ID - m.deleteName = m.users[m.cursor].Username - m.deleteTab = 5 - m.state = stateConfirmDelete - } - } - } - } - return m, nil -} - -func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - tabCount := 5 - if m.isAdmin { - tabCount = 6 - } - for i := 0; i < tabCount; i++ { - if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { - m.switchTab(i) - return m, nil - } - } - - if m.currentTab == 0 { - end := m.tableOffset + m.maxTableRows - if end > len(m.sites) { - end = len(m.sites) - } - for i := m.tableOffset; i < end; i++ { - if m.zones.Get(fmt.Sprintf("site-%d", i)).InBounds(msg) { - m.cursor = i - return m, nil - } - } - } - - if m.currentTab == 1 { - end := m.tableOffset + m.maxTableRows - if end > len(m.alerts) { - end = len(m.alerts) - } - for i := m.tableOffset; i < end; i++ { - if m.zones.Get(fmt.Sprintf("alert-%d", i)).InBounds(msg) { - m.cursor = i - return m, nil - } - } - } - - if m.currentTab == 4 { - end := m.tableOffset + m.maxTableRows - if end > len(m.maintenanceWindows) { - end = len(m.maintenanceWindows) - } - for i := m.tableOffset; i < end; i++ { - if m.zones.Get(fmt.Sprintf("maint-%d", i)).InBounds(msg) { - m.cursor = i - return m, nil - } - } - } - - if m.currentTab == 5 { - end := m.tableOffset + m.maxTableRows - if end > len(m.users) { - end = len(m.users) - } - for i := m.tableOffset; i < end; i++ { - if m.zones.Get(fmt.Sprintf("user-%d", i)).InBounds(msg) { - m.cursor = i - return m, nil - } - } - } - - return m, nil -} - -func (m *Model) switchTab(idx int) { - maxTabs := 4 - if m.isAdmin { - maxTabs = 5 - } - if idx > maxTabs { - idx = 0 - } - m.currentTab = idx - m.cursor = 0 - m.tableOffset = 0 - switch idx { - case 2: - m.state = stateLogs - case 5: - m.state = stateUsers - default: - m.state = stateDashboard - } -} - -func (m *Model) adjustCursor(newLen int) { - if m.cursor >= newLen && m.cursor > 0 { - m.cursor-- - } - if m.cursor < m.tableOffset { - m.tableOffset = m.cursor - if m.tableOffset < 0 { - m.tableOffset = 0 - } - } -} - -func (m *Model) refreshData() { - allSites := m.engine.GetAllSites() - - var groups, ungrouped []models.Site - children := make(map[int][]models.Site) - for _, s := range allSites { - if s.Type == "group" { - groups = append(groups, s) - } else if s.ParentID > 0 { - children[s.ParentID] = append(children[s.ParentID], s) - } else { - ungrouped = append(ungrouped, s) - } - } - sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID }) - for pid := range children { - c := children[pid] - sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID }) - sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) }) - children[pid] = c - } - sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID }) - sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) }) - - var ordered []models.Site - for _, g := range groups { - ordered = append(ordered, g) - if !m.collapsed[g.ID] { - ordered = append(ordered, children[g.ID]...) - } - } - ordered = append(ordered, ungrouped...) - if m.filterText != "" { - var filtered []models.Site - needle := strings.ToLower(m.filterText) - for _, s := range ordered { - if strings.Contains(strings.ToLower(s.Name), needle) { - filtered = append(filtered, s) - } - } - ordered = filtered - } - m.sites = ordered - if alerts, err := m.store.GetAllAlerts(); err == nil { - m.alerts = alerts - } - if m.isAdmin { - if users, err := m.store.GetAllUsers(); err == nil { - m.users = users - } - } - if nodes, err := m.store.GetAllNodes(); err == nil { - m.nodes = nodes - } - if windows, err := m.store.GetAllMaintenanceWindows(100); err == nil { - m.maintenanceWindows = windows - } - m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n")) - - listLen := len(m.sites) - switch m.currentTab { - case 1: - listLen = len(m.alerts) - case 3: - listLen = len(m.nodes) - case 4: - listLen = len(m.maintenanceWindows) - case 5: - 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() { - switch m.state { - case stateFormSite: - if m.siteFormData != nil { - m.submitSiteForm() - } - case stateFormAlert: - if m.alertFormData != nil { - m.submitAlertForm() - } - case stateFormUser: - if m.userFormData != nil { - m.submitUserForm() - } - case stateFormMaint: - if m.maintFormData != nil { - m.submitMaintForm() - } - } -} - -func (m Model) pulseIndicator() string { - hasDown := false - for _, s := range m.sites { - if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") { - hasDown = true - break - } - } - // Stills can't show animation: render a stable status dot in demo mode. - if m.demoMode { - c := m.theme.Success - if hasDown { - c = m.theme.Danger - } - return lipgloss.NewStyle().Foreground(c).Render("●") - } - frame := m.tickCount % len(pulseFrames) - brightness := int(m.pulsePos*155) + 100 - if brightness > 255 { - brightness = 255 - } - var color string - if hasDown { - color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4) - } else { - color = fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2) - } - return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame]) -} - -func (m Model) View() string { - switch m.state { - case stateConfirmDelete: - kind := "monitor" - switch m.deleteTab { - case 1: - kind = "alert" - case 4: - kind = "maintenance window" - case 5: - kind = "user" - } - msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) - hint := subtleStyle.Render("[y] Confirm [n] Cancel") - box := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(m.theme.Danger). - Padding(1, 3). - Render(msg + "\n\n" + hint) - return lipgloss.NewStyle().Padding(2, 4).Render(box) - case stateFormSite, stateFormAlert, stateFormUser, stateFormMaint: - if m.huhForm != nil { - title := "" - switch m.state { - case stateFormSite: - title = "Add Monitor" - if m.editID > 0 { - title = fmt.Sprintf("Edit Monitor #%d", m.editID) - } - case stateFormAlert: - title = "Add Alert" - if m.editID > 0 { - title = fmt.Sprintf("Edit Alert #%d", m.editID) - } - case stateFormUser: - title = "Add User" - if m.editID > 0 { - title = fmt.Sprintf("Edit User #%d", m.editID) - } - case stateFormMaint: - title = "New Maintenance Window" - } - formHeight := m.termHeight - 7 - if formHeight < 5 { - formHeight = 5 - } - m.huhForm.WithHeight(formHeight) - header := titleStyle.Render(title) - footer := subtleStyle.Render("\n[Esc] Cancel") - return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer) - } - return "" - case stateDetail: - return m.viewDetailPanel() - case stateAlertDetail: - return m.viewAlertDetailPanel() - default: - return m.zones.Scan(m.viewDashboard()) - } -} - -func (m Model) viewDashboard() string { - allSites := m.engine.GetAllSites() - totalMonitors := 0 - downCount := 0 - lateCount := 0 - for _, s := range allSites { - if s.Type == "group" { - continue - } - totalMonitors++ - if s.Paused || m.isMonitorInMaintenance(s.ID) { - continue - } - switch s.Status { - case "DOWN", "SSL EXP": - downCount++ - case "LATE": - lateCount++ - } - } - offlineNodes := 0 - for _, n := range m.nodes { - if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute { - offlineNodes++ - } - } - - var sitesLabel string - if downCount > 0 { - sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount) - } else if lateCount > 0 { - sitesLabel = fmt.Sprintf("Sites (%d⚠)", lateCount) - } else if totalMonitors > 0 { - sitesLabel = fmt.Sprintf("Sites (%d)", totalMonitors) - } else { - sitesLabel = "Sites" - } - var nodesLabel string - if offlineNodes > 0 { - nodesLabel = fmt.Sprintf("Nodes (%d!)", offlineNodes) - } else if len(m.nodes) > 0 { - nodesLabel = fmt.Sprintf("Nodes (%d)", len(m.nodes)) - } else { - nodesLabel = "Nodes" - } - - activeMaint := 0 - for _, mw := range m.maintenanceWindows { - now := time.Now() - if !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) { - activeMaint++ - } - } - var maintLabel string - if activeMaint > 0 { - maintLabel = fmt.Sprintf("Maint (%d)", activeMaint) - } else { - maintLabel = "Maint" - } - - tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel, maintLabel} - if m.isAdmin { - tabs = append(tabs, "Users") - } - var renderedTabs []string - for i, t := range tabs { - var rendered string - if i == m.currentTab { - rendered = activeTab.Render(t) - } else { - rendered = inactiveTab.Render(t) - } - renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered)) - } - header := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) - - pulse := m.pulseIndicator() - header = pulse + " " + header - - var content string - switch m.currentTab { - case 0: - content = m.viewSitesTab() - case 1: - content = m.viewAlertsTab() - case 2: - content = m.viewLogsTab() - case 3: - content = m.viewNodesTab() - case 4: - content = m.viewMaintTab() - case 5: - if m.isAdmin { - content = m.viewUsersTab() - } - } - - upCount := totalMonitors - downCount - lateCount - var upStr string - if downCount > 0 { - upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors)) - } else if lateCount > 0 { - upStr = warnStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors)) - } else { - upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors)) - } - statusParts := []string{upStr} - if lateCount > 0 { - statusParts = append(statusParts, warnStyle.Render(fmt.Sprintf("%d LATE", lateCount))) - } - if len(m.nodes) > 0 { - online := 0 - for _, n := range m.nodes { - if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second { - online++ - } - } - probeLabel := "probes" - if online == 1 { - probeLabel = "probe" - } - statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel)) - } - statusLine := strings.Join(statusParts, subtleStyle.Render(" · ")) - - var footer string - if m.filterMode { - cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│") - footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear") - } else { - var keys string - switch m.currentTab { - case 0: - keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit" - case 1: - keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit" - case 2: - keys = "[f]Filter [T]Theme [Tab]Switch [q]Quit" - case 4: - keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" - case 5: - keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit" - default: - keys = "[T]Theme [Tab]Switch [q]Quit" - } - footer = "\n" + statusLine + " " + subtleStyle.Render(keys) - if m.filterText != "" && m.currentTab == 0 { - footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys) - } - } - s := lipgloss.NewStyle().Padding(1, 2) - if m.termHeight > 0 { - s = s.MaxHeight(m.termHeight) - } - return s.Render(header + "\n" + content + "\n" + footer) -} - -func siteOrder(s models.Site) int { - if s.Paused { - return 3 - } - switch s.Status { - case "DOWN", "SSL EXP": - return 0 - case "LATE": - return 1 - case "PENDING": - return 3 - default: - return 2 - } -} - -func limitStr(text string, max int) string { - runes := []rune(text) - if len(runes) > max { - return string(runes[:max-3]) + "..." - } - return text -} diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..af9dcc3 --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,539 @@ +package tui + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.state == stateConfirmDelete { + return m.handleConfirmDelete(msg) + } + if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint { + return m.handleFormMsg(msg) + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + return m.handleResize(msg) + case time.Time: + return m.handleTick(msg) + case tea.MouseMsg: + return m.handleMouse(msg) + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "y", "Y": + switch m.deleteTab { + case 0: + if err := m.store.DeleteSite(m.deleteID); err != nil { + m.engine.AddLog("Delete site failed: " + err.Error()) + } + m.engine.RemoveSite(m.deleteID) + m.adjustCursor(len(m.sites) - 1) + case 1: + if err := m.store.DeleteAlert(m.deleteID); err != nil { + m.engine.AddLog("Delete alert failed: " + err.Error()) + } + m.adjustCursor(len(m.alerts) - 1) + case 4: + if err := m.store.DeleteMaintenanceWindow(m.deleteID); err != nil { + m.engine.AddLog("Delete maintenance window failed: " + err.Error()) + } + m.adjustCursor(len(m.maintenanceWindows) - 1) + case 5: + if err := m.store.DeleteUser(m.deleteID); err != nil { + m.engine.AddLog("Delete user failed: " + err.Error()) + } + m.adjustCursor(len(m.users) - 1) + } + m.refreshData() + m.state = stateDashboard + if m.deleteTab == 5 { + m.state = stateUsers + } + case "n", "N", "esc": + m.state = stateDashboard + if m.deleteTab == 5 { + m.state = stateUsers + } + case "ctrl+c": + return m, tea.Quit + } + return m, nil +} + +func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) { + if wsm, ok := msg.(tea.WindowSizeMsg); ok { + m.termWidth = wsm.Width + m.termHeight = wsm.Height + } + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg.String() == "ctrl+c" { + return m, tea.Quit + } + if keyMsg.String() == "esc" { + m.huhForm = nil + m.state = stateDashboard + if m.currentTab == 5 { + m.state = stateUsers + } + return m, nil + } + } + if m.huhForm != nil { + form, formCmd := m.huhForm.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.huhForm = f + } + if m.huhForm.State == huh.StateCompleted { + m.submitForm() + m.refreshData() + m.huhForm = nil + return m, nil + } + return m, formCmd + } + return m, nil +} + +func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + m.termWidth = msg.Width + m.termHeight = msg.Height + 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 - chromePadH + m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter) + return m, tea.ClearScreen +} + +func (m *Model) handleTick(t time.Time) (tea.Model, tea.Cmd) { + m.refreshData() + m.tickCount++ + target := sinApprox(float64(m.tickCount)*0.3)*0.5 + 0.5 + m.pulsePos, m.pulseVel = m.pulseSpring.Update(m.pulsePos, m.pulseVel, target) + return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t }) +} + +func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers { + return m, nil + } + if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { + return m.handleClick(msg) + } + if msg.Button != tea.MouseButtonWheelUp && msg.Button != tea.MouseButtonWheelDown { + return m, nil + } + + if m.state == stateLogs { + if msg.Button == tea.MouseButtonWheelUp { + m.logViewport.ScrollUp(3) + } else { + m.logViewport.ScrollDown(3) + } + return m, nil + } + + listLen := m.currentListLen() + if msg.Button == tea.MouseButtonWheelUp { + if m.cursor > 0 { + m.cursor-- + if m.cursor < m.tableOffset { + m.tableOffset = m.cursor + } + } + } else { + if m.cursor < listLen-1 { + m.cursor++ + if m.cursor >= m.tableOffset+m.maxTableRows { + m.tableOffset++ + } + } + } + return m, nil +} + +func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + if msg.String() == "ctrl+l" { + return m, tea.ClearScreen + } + + if m.filterMode { + return m.handleFilterKey(msg) + } + + switch m.state { + case stateDetail: + return m.handleDetailKey(msg) + case stateAlertDetail: + return m.handleAlertDetailKey(msg) + case stateDashboard, stateLogs, stateUsers: + return m.handleDashboardKey(msg) + } + return m, nil +} + +func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.filterMode = false + m.filterText = "" + m.cursor = 0 + m.tableOffset = 0 + m.refreshData() + case "enter": + m.filterMode = false + case "backspace": + if len(m.filterText) > 0 { + m.filterText = m.filterText[:len(m.filterText)-1] + m.cursor = 0 + m.tableOffset = 0 + m.refreshData() + } + case "ctrl+c": + return m, tea.Quit + default: + if len(msg.String()) == 1 { + m.filterText += msg.String() + m.cursor = 0 + m.tableOffset = 0 + m.refreshData() + } + } + return m, nil +} + +func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "i", "esc": + m.state = stateDashboard + case "q": + return m, tea.Quit + } + return m, nil +} + +func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "i", "esc": + m.state = stateDashboard + case "q": + return m, tea.Quit + } + return m, nil +} + +func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg.String() { + case "q": + return m, tea.Quit + case "/": + if m.currentTab == 0 { + m.filterMode = true + return m, nil + } + case "f": + if m.state == stateLogs { + m.logFilterImportant = !m.logFilterImportant + return m, nil + } + case "tab": + m.switchTab(m.currentTab + 1) + case "pgup", "pgdown": + if m.state == stateLogs { + m.logViewport, cmd = m.logViewport.Update(msg) + return m, cmd + } + case "up", "k": + if m.state == stateLogs { + m.logViewport.ScrollUp(1) + } else if m.cursor > 0 { + m.cursor-- + if m.cursor < m.tableOffset { + m.tableOffset = m.cursor + } + } + case "down", "j": + if m.state == stateLogs { + m.logViewport.ScrollDown(1) + } else { + max := m.currentListLen() - 1 + if m.cursor < max { + m.cursor++ + if m.cursor >= m.tableOffset+m.maxTableRows { + m.tableOffset++ + } + } + } + case "n": + return m.handleNewItem() + case "e", "enter": + return m.handleEditItem() + case "t": + if m.currentTab == 1 && len(m.alerts) > 0 { + a := m.alerts[m.cursor] + go func() { + if err := m.engine.TestAlert(a.ID); err != nil { + m.engine.AddLog(fmt.Sprintf("Test alert failed (%s): %v", a.Name, err)) + } + }() + return m, nil + } + case " ": + 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": + if m.currentTab == 0 && len(m.sites) > 0 { + site := m.sites[m.cursor] + m.engine.ToggleSitePause(site.ID) + site.Paused = !site.Paused + _ = m.store.UpdateSitePaused(site.ID, site.Paused) + m.refreshData() + } + case "i": + if m.currentTab == 0 && len(m.sites) > 0 { + m.state = stateDetail + } else if m.currentTab == 1 && len(m.alerts) > 0 { + m.state = stateAlertDetail + } + case "x": + if m.currentTab == 4 && len(m.maintenanceWindows) > 0 { + mw := m.maintenanceWindows[m.cursor] + now := time.Now() + isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) + if isActive { + if err := m.store.EndMaintenanceWindow(mw.ID); err != nil { + m.engine.AddLog("End maintenance failed: " + err.Error()) + } + m.refreshData() + } + } + case "T": + m.themeIndex = (m.themeIndex + 1) % len(themes) + m.theme = themes[m.themeIndex] + applyTheme(m.theme) + _ = m.store.SetPreference("theme", m.theme.Name) + case "d", "backspace": + return m.handleDeleteItem() + } + return m, nil +} + +func (m *Model) handleNewItem() (tea.Model, tea.Cmd) { + m.editID = 0 + m.editToken = "" + switch m.currentTab { + case 0: + m.state = stateFormSite + return m, m.initSiteHuhForm() + case 1: + m.state = stateFormAlert + return m, m.initAlertHuhForm() + case 4: + m.state = stateFormMaint + return m, m.initMaintHuhForm() + case 5: + if m.isAdmin { + m.state = stateFormUser + return m, m.initUserHuhForm() + } + } + return m, nil +} + +func (m *Model) handleEditItem() (tea.Model, tea.Cmd) { + switch m.currentTab { + case 0: + if len(m.sites) > 0 { + m.editID = m.sites[m.cursor].ID + m.editToken = m.sites[m.cursor].Token + m.state = stateFormSite + return m, m.initSiteHuhForm() + } + case 1: + if len(m.alerts) > 0 { + m.editID = m.alerts[m.cursor].ID + m.state = stateFormAlert + return m, m.initAlertHuhForm() + } + case 5: + if m.isAdmin && len(m.users) > 0 { + m.editID = m.users[m.cursor].ID + m.state = stateFormUser + return m, m.initUserHuhForm() + } + } + return m, nil +} + +func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) { + switch m.currentTab { + case 0: + if len(m.sites) > 0 { + m.deleteID = m.sites[m.cursor].ID + m.deleteName = m.sites[m.cursor].Name + m.deleteTab = 0 + m.state = stateConfirmDelete + } + case 1: + if len(m.alerts) > 0 { + m.deleteID = m.alerts[m.cursor].ID + m.deleteName = m.alerts[m.cursor].Name + m.deleteTab = 1 + m.state = stateConfirmDelete + } + case 4: + if len(m.maintenanceWindows) > 0 { + m.deleteID = m.maintenanceWindows[m.cursor].ID + m.deleteName = m.maintenanceWindows[m.cursor].Title + m.deleteTab = 4 + m.state = stateConfirmDelete + } + case 5: + if m.isAdmin && len(m.users) > 0 { + m.deleteID = m.users[m.cursor].ID + m.deleteName = m.users[m.cursor].Username + m.deleteTab = 5 + m.state = stateConfirmDelete + } + } + return m, nil +} + +func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + tabCount := 5 + if m.isAdmin { + tabCount = 6 + } + for i := 0; i < tabCount; i++ { + if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { + m.switchTab(i) + return m, nil + } + } + + prefix, listLen := m.currentZonePrefix() + end := m.tableOffset + m.maxTableRows + if end > listLen { + end = listLen + } + for i := m.tableOffset; i < end; i++ { + if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) { + m.cursor = i + return m, nil + } + } + + return m, nil +} + +func (m *Model) switchTab(idx int) { + maxTabs := 4 + if m.isAdmin { + maxTabs = 5 + } + if idx > maxTabs { + idx = 0 + } + m.currentTab = idx + m.cursor = 0 + m.tableOffset = 0 + switch idx { + case 2: + m.state = stateLogs + case 5: + m.state = stateUsers + default: + m.state = stateDashboard + } +} + +func (m *Model) adjustCursor(newLen int) { + if m.cursor >= newLen && m.cursor > 0 { + m.cursor-- + } + if m.cursor < m.tableOffset { + m.tableOffset = m.cursor + if m.tableOffset < 0 { + m.tableOffset = 0 + } + } +} + +func (m *Model) submitForm() { + switch m.state { + case stateFormSite: + if m.siteFormData != nil { + m.submitSiteForm() + } + case stateFormAlert: + if m.alertFormData != nil { + m.submitAlertForm() + } + case stateFormUser: + if m.userFormData != nil { + m.submitUserForm() + } + case stateFormMaint: + if m.maintFormData != nil { + m.submitMaintForm() + } + } +} + +func (m Model) currentListLen() int { + switch m.currentTab { + case 1: + return len(m.alerts) + case 3: + return len(m.nodes) + case 4: + return len(m.maintenanceWindows) + case 5: + return len(m.users) + default: + return len(m.sites) + } +} + +func (m Model) currentZonePrefix() (string, int) { + switch m.currentTab { + case 0: + return "site", len(m.sites) + case 1: + return "alert", len(m.alerts) + case 4: + return "maint", len(m.maintenanceWindows) + case 5: + return "user", len(m.users) + default: + return "site", 0 + } +} diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go new file mode 100644 index 0000000..bdd3646 --- /dev/null +++ b/internal/tui/view_dashboard.go @@ -0,0 +1,279 @@ +package tui + +import ( + "fmt" + "math" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +func sinApprox(x float64) float64 { + return math.Sin(x) +} + +func (m Model) pulseIndicator() string { + hasDown := false + for _, s := range m.sites { + if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") { + hasDown = true + break + } + } + if m.demoMode { + c := m.theme.Success + if hasDown { + c = m.theme.Danger + } + return lipgloss.NewStyle().Foreground(c).Render("●") + } + frame := m.tickCount % len(pulseFrames) + brightness := int(m.pulsePos*155) + 100 + if brightness > 255 { + brightness = 255 + } + var color string + if hasDown { + color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4) + } else { + color = fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2) + } + return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame]) +} + +func (m Model) View() string { + switch m.state { + case stateConfirmDelete: + kind := "monitor" + switch m.deleteTab { + case 1: + kind = "alert" + case 4: + kind = "maintenance window" + case 5: + kind = "user" + } + msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) + hint := subtleStyle.Render("[y] Confirm [n] Cancel") + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.theme.Danger). + Padding(1, 3). + Render(msg + "\n\n" + hint) + return lipgloss.NewStyle().Padding(2, 4).Render(box) + case stateFormSite, stateFormAlert, stateFormUser, stateFormMaint: + if m.huhForm != nil { + title := "" + switch m.state { + case stateFormSite: + title = "Add Monitor" + if m.editID > 0 { + title = fmt.Sprintf("Edit Monitor #%d", m.editID) + } + case stateFormAlert: + title = "Add Alert" + if m.editID > 0 { + title = fmt.Sprintf("Edit Alert #%d", m.editID) + } + case stateFormUser: + title = "Add User" + if m.editID > 0 { + title = fmt.Sprintf("Edit User #%d", m.editID) + } + case stateFormMaint: + title = "New Maintenance Window" + } + formHeight := m.termHeight - 7 + if formHeight < 5 { + formHeight = 5 + } + m.huhForm.WithHeight(formHeight) + header := titleStyle.Render(title) + footer := subtleStyle.Render("\n[Esc] Cancel") + return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer) + } + return "" + case stateDetail: + return m.viewDetailPanel() + case stateAlertDetail: + return m.viewAlertDetailPanel() + default: + return m.zones.Scan(m.viewDashboard()) + } +} + +type dashboardStats struct { + totalMonitors int + downCount int + lateCount int + offlineNodes int + activeMaint int +} + +func (m Model) computeStats() dashboardStats { + allSites := m.engine.GetAllSites() + var s dashboardStats + for _, site := range allSites { + if site.Type == "group" { + continue + } + s.totalMonitors++ + if site.Paused || m.isMonitorInMaintenance(site.ID) { + continue + } + switch site.Status { + case "DOWN", "SSL EXP": + s.downCount++ + case "LATE": + s.lateCount++ + } + } + for _, n := range m.nodes { + if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute { + s.offlineNodes++ + } + } + for _, mw := range m.maintenanceWindows { + now := time.Now() + if !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) { + s.activeMaint++ + } + } + return s +} + +func (m Model) viewDashboard() string { + stats := m.computeStats() + + header := m.renderTabBar(stats) + header = m.pulseIndicator() + " " + header + + var content string + switch m.currentTab { + case 0: + content = m.viewSitesTab() + case 1: + content = m.viewAlertsTab() + case 2: + content = m.viewLogsTab() + case 3: + content = m.viewNodesTab() + case 4: + content = m.viewMaintTab() + case 5: + if m.isAdmin { + content = m.viewUsersTab() + } + } + + footer := m.renderFooter(stats) + + s := lipgloss.NewStyle().Padding(1, 2) + if m.termHeight > 0 { + s = s.MaxHeight(m.termHeight) + } + return s.Render(header + "\n" + content + "\n" + footer) +} + +func (m Model) renderTabBar(stats dashboardStats) string { + var sitesLabel string + if stats.downCount > 0 { + sitesLabel = fmt.Sprintf("Sites (%d↓)", stats.downCount) + } else if stats.lateCount > 0 { + sitesLabel = fmt.Sprintf("Sites (%d⚠)", stats.lateCount) + } else if stats.totalMonitors > 0 { + sitesLabel = fmt.Sprintf("Sites (%d)", stats.totalMonitors) + } else { + sitesLabel = "Sites" + } + + var nodesLabel string + if stats.offlineNodes > 0 { + nodesLabel = fmt.Sprintf("Nodes (%d!)", stats.offlineNodes) + } else if len(m.nodes) > 0 { + nodesLabel = fmt.Sprintf("Nodes (%d)", len(m.nodes)) + } else { + nodesLabel = "Nodes" + } + + var maintLabel string + if stats.activeMaint > 0 { + maintLabel = fmt.Sprintf("Maint (%d)", stats.activeMaint) + } else { + maintLabel = "Maint" + } + + tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel, maintLabel} + if m.isAdmin { + tabs = append(tabs, "Users") + } + var renderedTabs []string + for i, t := range tabs { + var rendered string + if i == m.currentTab { + rendered = activeTab.Render(t) + } else { + rendered = inactiveTab.Render(t) + } + renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered)) + } + return lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) +} + +func (m Model) renderFooter(stats dashboardStats) string { + if m.filterMode { + cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│") + return "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear") + } + + upCount := stats.totalMonitors - stats.downCount - stats.lateCount + var upStr string + if stats.downCount > 0 { + upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) + } else if stats.lateCount > 0 { + upStr = warnStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) + } else { + upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) + } + statusParts := []string{upStr} + if stats.lateCount > 0 { + statusParts = append(statusParts, warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount))) + } + if len(m.nodes) > 0 { + online := 0 + for _, n := range m.nodes { + if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second { + online++ + } + } + probeLabel := "probes" + if online == 1 { + probeLabel = "probe" + } + statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel)) + } + statusLine := strings.Join(statusParts, subtleStyle.Render(" · ")) + + var keys string + switch m.currentTab { + case 0: + keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit" + case 1: + keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit" + case 2: + keys = "[f]Filter [T]Theme [Tab]Switch [q]Quit" + case 4: + keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" + case 5: + keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit" + default: + keys = "[T]Theme [Tab]Switch [q]Quit" + } + + footer := "\n" + statusLine + " " + subtleStyle.Render(keys) + if m.filterText != "" && m.currentTab == 0 { + footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys) + } + return footer +} diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go new file mode 100644 index 0000000..c7f39c1 --- /dev/null +++ b/internal/tui/view_detail.go @@ -0,0 +1,207 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +func (m Model) viewDetailPanel() string { + if m.cursor >= len(m.sites) { + return "" + } + site := m.sites[m.cursor] + hist, _ := m.engine.GetHistory(site.ID) + + var b strings.Builder + + 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) { + fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value) + } + + section := func(label string) { + b.WriteString("\n" + subtleStyle.Render(" "+label) + "\n") + } + + row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))) + + if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" { + row("Error", dangerStyle.Render(limitStr(site.LastError, 60))) + } + + if site.Type == "http" && site.StatusCode > 0 { + row("HTTP Code", strconv.Itoa(site.StatusCode)) + } + + if !site.StatusChangedAt.IsZero() { + dur := time.Since(site.StatusChangedAt) + row("State Since", site.StatusChangedAt.Format("2006-01-02 15:04:05")+" ("+fmtDuration(dur)+")") + } + + if !site.LastSuccessAt.IsZero() { + ago := time.Since(site.LastSuccessAt) + row("Last Success", site.LastSuccessAt.Format("15:04:05")+" ("+fmtDuration(ago)+" ago)") + } + + if m.isMonitorInMaintenance(site.ID) { + for _, mw := range m.maintenanceWindows { + if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) { + row("Maintenance", maintStyle.Render(mw.Title)) + break + } + } + } + + section("ENDPOINT") + row("Type", site.Type) + if site.URL != "" { + row("URL", site.URL) + } + if site.Hostname != "" { + row("Host", site.Hostname) + } + if site.Port > 0 { + row("Port", strconv.Itoa(site.Port)) + } + + section("TIMING") + row("Interval", fmt.Sprintf("%ds", site.Interval)) + if site.Timeout > 0 { + row("Timeout", fmt.Sprintf("%ds", site.Timeout)) + } + row("Latency", fmtLatency(site.Latency)) + row("Uptime", fmtUptime(hist.Statuses)) + if !site.LastCheck.IsZero() { + row("Last Check", site.LastCheck.Format("15:04:05")) + } + + if site.Type == "http" { + section("HTTP") + if site.Method != "" && site.Method != "GET" { + row("Method", site.Method) + } + codes := site.AcceptedCodes + if codes == "" { + codes = "200-299" + } + row("Codes", codes) + row("SSL", fmtSSL(site)) + if site.IgnoreTLS { + row("TLS Verify", dangerStyle.Render("disabled")) + } + } + + if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" { + section("CONFIG") + if site.MaxRetries > 0 { + row("Retries", fmtRetries(site)) + } + if site.Regions != "" { + row("Regions", site.Regions) + } + if site.Description != "" { + row("Description", site.Description) + } + } + + probeResults := m.engine.GetProbeResults(site.ID) + if len(probeResults) > 0 { + b.WriteString("\n" + subtleStyle.Render(" PROBE RESULTS") + "\n") + for nodeID, result := range probeResults { + status := specialStyle.Render("UP") + if !result.IsUp { + status = dangerStyle.Render("DN") + } + latency := time.Duration(result.LatencyNs).Milliseconds() + ago := time.Since(result.CheckedAt).Truncate(time.Second) + line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago) + if !result.IsUp && result.ErrorReason != "" { + line += " " + dangerStyle.Render(limitStr(result.ErrorReason, 30)) + } + b.WriteString(line + "\n") + } + } + + stateChanges := m.engine.GetStateChanges(site.ID, 5) + if len(stateChanges) > 0 { + b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n") + for _, sc := range stateChanges { + ago := fmtDuration(time.Since(sc.ChangedAt)) + arrow := subtleStyle.Render(sc.FromStatus) + " → " + if sc.ToStatus == "UP" { + arrow += specialStyle.Render(sc.ToStatus) + } else { + arrow += dangerStyle.Render(sc.ToStatus) + } + line := fmt.Sprintf(" %s %s", arrow, subtleStyle.Render(ago+" ago")) + if sc.ErrorReason != "" && sc.ToStatus != "UP" { + line += " " + dangerStyle.Render(limitStr(sc.ErrorReason, 40)) + } + b.WriteString(line + "\n") + } + } + + b.WriteString("\n") + 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++ + } + } + fmt.Fprintf(&b, "\n %s %d/%d checks up", + subtleStyle.Render("Heartbeats"), + up, len(hist.Statuses)) + } + } else { + b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth)) + var minL, maxL, total time.Duration + count := 0 + for i, l := range hist.Latencies { + if i < len(hist.Statuses) && !hist.Statuses[i] { + continue + } + if count == 0 { + minL, maxL = l, l + } else if l < minL { + minL = l + } else if l > maxL { + maxL = l + } + total += l + count++ + } + if count > 0 { + avg := total / time.Duration(count) + fmt.Fprintf(&b, "\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(subtleStyle.Render(" [i/Esc] Back [e] Edit [q] Quit")) + + return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) +}