diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 76a55bb..1bb02fe 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -243,7 +243,7 @@ func checkByID(id int) { case "dns": checkDNS(site) case "group": - // groups don't perform checks + checkGroup(site) } } @@ -437,6 +437,44 @@ func checkPort(site models.Site) { handleStatusChange(updatedSite, "UP", 0, latency) } +func checkGroup(site models.Site) { + Mutex.RLock() + status := "UP" + hasChildren := false + allPaused := true + for _, child := range LiveState { + if child.ParentID != site.ID || child.Type == "group" { + continue + } + hasChildren = true + if !child.Paused { + allPaused = false + } + if child.Paused { + continue + } + if child.Status == "DOWN" || child.Status == "SSL EXP" { + status = "DOWN" + } else if child.Status == "PENDING" && status != "DOWN" { + status = "PENDING" + } + } + Mutex.RUnlock() + + if !hasChildren { + status = "PENDING" + } + + Mutex.Lock() + s := LiveState[site.ID] + s.Status = status + if hasChildren && allPaused { + s.Paused = true + } + LiveState[site.ID] = s + Mutex.Unlock() +} + func checkDNS(site models.Site) { host := site.Hostname if host == "" { diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index fecd183..3a590a0 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -34,6 +34,11 @@ var ( siteBorderStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#444")) + + siteGroupStyle = lipgloss.NewStyle(). + Padding(0, 1). + Bold(true). + Foreground(lipgloss.Color("#7D56F4")) ) type siteFormData struct { @@ -50,6 +55,7 @@ type siteFormData struct { Timeout string Description string IgnoreTLS bool + GroupID string } func latencySparkline(latencies []time.Duration, width int) string { @@ -222,10 +228,42 @@ func (m Model) viewSitesTab() string { selectedVisual := m.cursor - m.tableOffset var rows [][]string + var groupRows []int for i := m.tableOffset; i < end; i++ { site := m.sites[i] - hist, _ := monitor.GetHistory(site.ID) + if site.Type == "group" { + groupRows = append(groupRows, i-m.tableOffset) + arrow := "▾" + if m.collapsed[site.ID] { + arrow = "▸" + } + rows = append(rows, []string{ + strconv.Itoa(i + 1), + m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, 11)), + "group", + fmtStatus(site.Status, site.Paused), + subtleStyle.Render("—"), + subtleStyle.Render("—"), + subtleStyle.Render(strings.Repeat("·", sparkWidth)), + subtleStyle.Render("-"), + subtleStyle.Render("—"), + }) + continue + } + + name := site.Name + if site.ParentID > 0 { + prefix := "├" + if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID { + prefix = "└" + } + name = prefix + " " + limitStr(name, 11) + } else { + name = limitStr(name, 13) + } + + hist, _ := monitor.GetHistory(site.ID) var spark string if site.Type == "push" { spark = heartbeatSparkline(hist.Statuses, sparkWidth) @@ -235,7 +273,7 @@ func (m Model) viewSitesTab() string { rows = append(rows, []string{ strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("site-%d", i), limitStr(site.Name, 13)), + m.zones.Mark(fmt.Sprintf("site-%d", i), name), site.Type, fmtStatus(site.Status, site.Paused), fmtLatency(site.Latency), @@ -246,6 +284,15 @@ func (m Model) viewSitesTab() string { }) } + isGroupRow := func(row int) bool { + for _, g := range groupRows { + if g == row { + return true + } + } + return false + } + tableWidth := m.termWidth - 6 if tableWidth < 40 { tableWidth = 40 @@ -264,6 +311,9 @@ func (m Model) viewSitesTab() string { if row == selectedVisual { return siteSelectedStyle } + if isGroupRow(row) { + return siteGroupStyle + } return siteCellStyle }) @@ -278,6 +328,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd { Retries: "0", Timeout: "5", Port: "0", + GroupID: "0", } if m.editID > 0 { @@ -296,6 +347,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd { m.siteFormData.Timeout = strconv.Itoa(site.Timeout) m.siteFormData.Description = site.Description m.siteFormData.IgnoreTLS = site.IgnoreTLS + m.siteFormData.GroupID = strconv.Itoa(site.ParentID) break } } @@ -311,6 +363,13 @@ func (m *Model) initSiteHuhForm() tea.Cmd { } } + groupOpts := []huh.Option[string]{huh.NewOption("None", "0")} + for _, s := range m.sites { + if s.Type == "group" && s.ID != m.editID { + groupOpts = append(groupOpts, huh.NewOption(s.Name, strconv.Itoa(s.ID))) + } + } + m.huhForm = huh.NewForm( huh.NewGroup( huh.NewInput().Title("Monitor Name"). @@ -331,12 +390,17 @@ func (m *Model) initSiteHuhForm() tea.Cmd { huh.NewOption("DNS", "dns"), huh.NewOption("Group", "group"), ).Value(&m.siteFormData.SiteType), + huh.NewSelect[string]().Title("Alert Channel"). + Options(alertOpts...). + Value(&m.siteFormData.AlertID), + ).Title("Monitor Settings"), + huh.NewGroup( huh.NewInput().Title("URL"). Placeholder("https://example.com"). Description("Required for HTTP monitors"). Value(&m.siteFormData.URL). Validate(func(s string) error { - if m.siteFormData.SiteType == "push" { + if m.siteFormData.SiteType == "push" || m.siteFormData.SiteType == "group" { return nil } if s == "" { @@ -358,6 +422,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd { Placeholder("60"). Value(&m.siteFormData.Interval). Validate(func(s string) error { + if m.siteFormData.SiteType == "group" { + return nil + } v, err := strconv.Atoi(s) if err != nil { return fmt.Errorf("must be a number") @@ -367,11 +434,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd { } return nil }), - huh.NewSelect[string]().Title("Alert Channel"). - Options(alertOpts...). - Value(&m.siteFormData.AlertID), - ).Title("Monitor Settings"), - huh.NewGroup( + huh.NewSelect[string]().Title("Parent Group"). + Options(groupOpts...). + Value(&m.siteFormData.GroupID), huh.NewInput().Title("Hostname / IP"). Placeholder("10.0.0.1"). Description("Target for ping/port/DNS monitors"). @@ -394,6 +459,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd { Placeholder("5"). Value(&m.siteFormData.Timeout). Validate(func(s string) error { + if m.siteFormData.SiteType == "group" { + return nil + } v, err := strconv.Atoi(s) if err != nil { return fmt.Errorf("must be a number") @@ -406,7 +474,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd { huh.NewInput().Title("Description"). Placeholder("Optional description"). Value(&m.siteFormData.Description), - ).Title("Connection"), + ).Title("Connection").WithHideFunc(func() bool { + return m.siteFormData.SiteType == "group" + }), huh.NewGroup( huh.NewConfirm().Title("Monitor SSL Certificate?"). Value(&m.siteFormData.CheckSSL), @@ -438,7 +508,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd { }), huh.NewConfirm().Title("Ignore TLS Errors?"). Value(&m.siteFormData.IgnoreTLS), - ).Title("Advanced"), + ).Title("Advanced").WithHideFunc(func() bool { + return m.siteFormData.SiteType == "group" + }), ).WithTheme(huh.ThemeDracula()) return m.huhForm.Init() @@ -452,6 +524,7 @@ func (m *Model) submitSiteForm() { retries, _ := strconv.Atoi(d.Retries) port, _ := strconv.Atoi(d.Port) timeout, _ := strconv.Atoi(d.Timeout) + groupID, _ := strconv.Atoi(d.GroupID) if interval < 1 { interval = 60 } @@ -474,6 +547,7 @@ func (m *Model) submitSiteForm() { Timeout: timeout, Description: d.Description, IgnoreTLS: d.IgnoreTLS, + ParentID: groupID, } if m.editID > 0 { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 174d5c8..4972a3a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -67,6 +67,8 @@ type Model struct { deleteName string deleteTab int + collapsed map[int]bool + // harmonica animation state pulseSpring harmonica.Spring pulsePos float64 @@ -90,6 +92,7 @@ func InitialModel(isAdmin bool) Model { isAdmin: isAdmin, zones: z, pulseSpring: spring, + collapsed: make(map[int]bool), } } @@ -299,6 +302,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = stateFormUser return m, m.initUserHuhForm() } + 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] + m.refreshData() + } case "p": if m.currentTab == 0 && len(m.sites) > 0 { site := m.sites[m.cursor] @@ -421,13 +430,40 @@ func (m *Model) adjustCursor(newLen int) { func (m *Model) refreshData() { monitor.Mutex.RLock() - var sites []models.Site + var allSites []models.Site for _, s := range monitor.LiveState { - sites = append(sites, s) + allSites = append(allSites, s) } monitor.Mutex.RUnlock() - sort.Slice(sites, func(i, j int) bool { return sites[i].ID < sites[j].ID }) - m.sites = sites + + 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 }) + children[pid] = c + } + sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID }) + + 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...) + m.sites = ordered if store.Get() != nil { m.alerts = store.Get().GetAllAlerts() if m.isAdmin { @@ -543,7 +579,7 @@ func (m Model) viewDashboard() string { } } - footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [p] Pause [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") + footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [p] Pause [Space] Collapse [Tab/Click] Switch [q] Quit") if m.currentTab == 3 { footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") }