diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index b86d905..a9d6d2b 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strings" "time" "gitea.lerkolabs.com/lerko/uptop/internal/monitor" @@ -147,8 +148,20 @@ func (m Model) viewAlertsTab() string { return "\n No alert channels configured. Press [n] to add one." } + var headers []string + var widths []int + if m.isWide() { + headers = []string{"#", "", "NAME", "TYPE", "CONFIG", "LAST SENT"} + widths = []int{4, 3, 18, 12, 40, 12} + } else { + headers = []string{"#", "", "NAME", "TYPE", "CONFIG", "SENT"} + widths = []int{4, 3, 14, 10, 24, 8} + } + nameW := widths[2] + cfgW := widths[4] + return m.renderTable( - []string{"#", "", "NAME", "TYPE", "CONFIG", "LAST SENT"}, + headers, len(m.alerts), func(start, end int) [][]string { var rows [][]string @@ -158,21 +171,67 @@ func (m Model) viewAlertsTab() string { rows = append(rows, []string{ fmt.Sprintf("%d", i+1), fmtAlertHealth(h), - m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, 15)), + m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)), fmtAlertType(a.Type), - fmtAlertConfig(struct { + limitStr(fmtAlertConfig(struct { Type string Settings map[string]string - }{a.Type, a.Settings}), + }{a.Type, a.Settings}), cfgW-2), fmtAlertLastSent(h), }) } return rows }, - nil, nil, + widths, nil, ) } +func (m Model) viewAlertDetailPanel() string { + if m.cursor >= len(m.alerts) { + return "" + } + a := m.alerts[m.cursor] + h := m.engine.GetAlertHealth(a.ID) + + var b strings.Builder + + b.WriteString(subtleStyle.Render(" Alerts > ") + titleStyle.Render(a.Name) + "\n\n") + + row := func(label, value string) { + fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value) + } + + row("Type", fmtAlertType(a.Type)) + + if h.LastSendAt.IsZero() { + row("Health", subtleStyle.Render("never sent")) + } else if h.LastSendOK { + row("Health", specialStyle.Render("OK")) + } else { + row("Health", dangerStyle.Render("FAILED")) + } + + if !h.LastSendAt.IsZero() { + row("Last Sent", h.LastSendAt.Format("2006-01-02 15:04:05")+" ("+fmtAlertLastSent(h)+")") + } + if h.SendCount > 0 { + row("Sends", fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount)) + } + if h.LastError != "" { + row("Last Error", dangerStyle.Render(limitStr(h.LastError, 60))) + } + + b.WriteString("\n" + subtleStyle.Render(" CONFIGURATION") + "\n") + for k, v := range a.Settings { + row(k, v) + } + + b.WriteString("\n\n") + b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [t] Test [q] Quit")) + + return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) +} + func (m *Model) initAlertHuhForm() tea.Cmd { m.alertFormData = &alertFormData{ AlertType: "discord", diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index 68d6141..0f11842 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -2,10 +2,11 @@ package tui import ( "fmt" - "gitea.lerkolabs.com/lerko/uptop/internal/models" "strconv" "time" + "gitea.lerkolabs.com/lerko/uptop/internal/models" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" @@ -40,19 +41,19 @@ func fmtMaintType(t string) string { return maintStyle.Render("maintenance") } -func fmtMaintMonitor(monitorID int, sites []models.Site) string { +func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string { if monitorID == 0 { return "All" } for _, s := range sites { if s.ID == monitorID { - return limitStr(s.Name, 18) + return limitStr(s.Name, maxW) } } return fmt.Sprintf("#%d", monitorID) } -func fmtMaintTime(t time.Time) string { +func fmtMaintTime(t time.Time, colW int) string { if t.IsZero() { return subtleStyle.Render("—") } @@ -60,7 +61,10 @@ func fmtMaintTime(t time.Time) string { if t.Year() == now.Year() && t.YearDay() == now.YearDay() { return t.Format("15:04") } - return t.Format("15:04 Jan 02") + if colW >= 14 { + return t.Format("15:04 Jan 02") + } + return t.Format("Jan 02") } func (m Model) isMonitorInMaintenance(monitorID int) bool { @@ -92,8 +96,21 @@ func (m Model) viewMaintTab() string { return "\n No maintenance windows or incidents. Press [n] to create one." } + var headers []string + var widths []int + if m.isWide() { + headers = []string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"} + widths = []int{4, 24, 14, 22, 12, 16, 16} + } else { + headers = []string{"#", "TITLE", "TYPE", "MON", "ST", "START", "ENDS"} + widths = []int{4, 14, 13, 14, 11, 14, 14} + } + titleW := widths[1] + monW := widths[3] + timeW := widths[5] + return m.renderTable( - []string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"}, + headers, len(m.maintenanceWindows), func(start, end int) [][]string { var rows [][]string @@ -102,17 +119,17 @@ func (m Model) viewMaintTab() string { mw := m.maintenanceWindows[i] rows = append(rows, []string{ strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, 24)), + m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, titleW-2)), fmtMaintType(mw.Type), - fmtMaintMonitor(mw.MonitorID, allSites), + fmtMaintMonitorW(mw.MonitorID, allSites, monW-2), fmtMaintStatus(mw), - fmtMaintTime(mw.StartTime), - fmtMaintTime(mw.EndTime), + fmtMaintTime(mw.StartTime, timeW), + fmtMaintTime(mw.EndTime, timeW), }) } return rows }, - []int{6, 0, 14, 20, 12, 16, 16}, + widths, nil, ) } diff --git a/internal/tui/tab_nodes.go b/internal/tui/tab_nodes.go index a4a049c..3ddb79d 100644 --- a/internal/tui/tab_nodes.go +++ b/internal/tui/tab_nodes.go @@ -10,16 +10,25 @@ func (m Model) viewNodesTab() string { return "\n No probe nodes connected." } - colWidths := []int{0, 12, 20, 10, 8} + var headers []string + var widths []int + if m.isWide() { + headers = []string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"} + widths = []int{24, 14, 16, 12, 10} + } else { + headers = []string{"NAME", "REGION", "SEEN", "VER", "STATUS"} + widths = []int{16, 10, 10, 8, 8} + } + nameW := widths[0] return m.renderTable( - []string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}, + headers, len(m.nodes), func(start, end int) [][]string { var rows [][]string for i := start; i < end; i++ { node := m.nodes[i] - name := limitStr(node.Name, 20) + name := limitStr(node.Name, nameW-2) if name == "" { name = node.ID } @@ -37,7 +46,7 @@ func (m Model) viewNodesTab() string { } return rows }, - colWidths, + widths, nil, ) } diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 838ea00..13f45a9 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -334,28 +334,71 @@ func fmtDuration(d time.Duration) string { return fmt.Sprintf("%dd", days) } -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 - chromePadH - 2 - fixed - overhead - if avail < 30 { - avail = 30 +type tableLayout struct { + nameW, sparkW int + headers []string + colWidths []int +} + +func (m Model) computeLayout() tableLayout { + wide := m.isWide() + + var fixed int + var headers []string + 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 + } + + numCols := len(headers) + borderOverhead := 2 + (numCols - 1) + avail := m.termWidth - chromePadH - 2 - borderOverhead - fixed + if avail < 20 { + avail = 20 + } + + maxName := 0 + for _, s := range m.sites { + if n := len([]rune(s.Name)); n > maxName { + maxName = n + } + } + maxName += 4 + + nameW := avail / 2 + if nameW > maxName { + nameW = maxName } - nameW = avail / 2 - sparkW = avail - nameW - 2 // -2 for spark column padding if nameW < 13 { nameW = 13 } if nameW > 40 { nameW = 40 } + + sparkW := avail - nameW if sparkW < 10 { sparkW = 10 } - if sparkW > 60 { - sparkW = 60 + + widths[1] = nameW + widths[6] = sparkW + + return tableLayout{ + nameW: nameW, + sparkW: sparkW, + headers: headers, + colWidths: widths, } - return } func (m Model) viewSitesTab() string { @@ -373,12 +416,16 @@ func (m Model) viewSitesTab() string { return "\n" + welcome } - nameW, sparkWidth := m.dynamicWidths() - colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 2, 7, 9} + layout := m.computeLayout() + nameW := layout.nameW + sparkWidth := layout.sparkW - 2 + if sparkWidth < 8 { + sparkWidth = 8 + } var groupRows map[int]bool return m.renderTable( - []string{"#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY"}, + layout.headers, len(m.sites), func(start, end int) [][]string { groupRows = make(map[int]bool) @@ -391,7 +438,7 @@ func (m Model) viewSitesTab() string { icon := typeIcon("group", m.collapsed[site.ID]) rows = append(rows, []string{ strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-2)), + m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)), "group", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), subtleStyle.Render("—"), @@ -409,14 +456,14 @@ func (m Model) viewSitesTab() string { if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID { prefix = "└" } - name = prefix + " " + limitStr(name, nameW-2) + name = prefix + " " + limitStr(name, nameW-4) } else { - name = limitStr(name, nameW) + name = limitStr(name, nameW-2) } if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" { nameLen := len([]rune(name)) - errSpace := nameW - nameLen - 1 + errSpace := nameW - nameLen - 3 if errSpace > 10 { name = name + " " + subtleStyle.Render(limitStr(site.LastError, errSpace)) } @@ -444,7 +491,7 @@ func (m Model) viewSitesTab() string { } return rows }, - colWidths, + layout.colWidths, func(row, col int) *lipgloss.Style { if groupRows[row] { s := siteGroupStyle @@ -764,6 +811,10 @@ func (m Model) viewDetailPanel() 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 != "" { @@ -792,6 +843,8 @@ func (m Model) viewDetailPanel() string { } } } + + section("ENDPOINT") row("Type", site.Type) if site.URL != "" { row("URL", site.URL) @@ -802,31 +855,45 @@ func (m Model) viewDetailPanel() string { if site.Port > 0 { row("Port", strconv.Itoa(site.Port)) } + + section("TIMING") row("Interval", fmt.Sprintf("%ds", site.Interval)) - row("Timeout", fmt.Sprintf("%ds", site.Timeout)) + 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" { - row("Method", site.Method) - row("Codes", site.AcceptedCodes) + 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 { - row("Retries", fmtRetries(site)) - } - if site.Regions != "" { - row("Regions", site.Regions) - } - if site.Description != "" { - row("Description", site.Description) - } - if !site.LastCheck.IsZero() { - row("Last Check", site.LastCheck.Format("15:04:05")) + 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) diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 4793933..a02badd 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -32,8 +32,19 @@ func (m Model) viewUsersTab() string { return "\n No users configured. Press [n] to add one." } + var headers []string + var widths []int + if m.isWide() { + headers = []string{"#", "USERNAME", "ROLE", "PUBLIC KEY"} + widths = []int{4, 18, 10, 50} + } else { + headers = []string{"#", "USER", "ROLE", "KEY"} + widths = []int{4, 14, 8, 30} + } + userW := widths[1] + return m.renderTable( - []string{"#", "USERNAME", "ROLE", "PUBLIC KEY"}, + headers, len(m.users), func(start, end int) [][]string { var rows [][]string @@ -41,14 +52,14 @@ func (m Model) viewUsersTab() string { u := m.users[i] rows = append(rows, []string{ fmt.Sprintf("%d", i+1), - m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)), + m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, userW-2)), fmtRole(u.Role), fmtKey(u.PublicKey), }) } return rows }, - nil, nil, + widths, nil, ) } diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index 0f37e72..8b9f2d4 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -15,6 +15,12 @@ var ( type StyleOverride func(row, col int) *lipgloss.Style +const wideBreakpoint = 120 + +func (m Model) isWide() bool { + return m.termWidth >= wideBreakpoint +} + func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string { if items == 0 { return "" @@ -28,7 +34,16 @@ 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 - chromePadH - 2 + colTotal := 0 + for _, w := range colWidths { + colTotal += w + } + borderOverhead := 2 + len(colWidths) - 1 + tableWidth := colTotal + borderOverhead + maxWidth := m.termWidth - chromePadH - 2 + if tableWidth > maxWidth { + tableWidth = maxWidth + } if tableWidth < 40 { tableWidth = 40 } @@ -41,7 +56,11 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en Rows(rows...). StyleFunc(func(row, col int) lipgloss.Style { if row == table.HeaderRow { - return tableHeaderStyle + h := tableHeaderStyle + if col < len(colWidths) && colWidths[col] > 0 { + h = h.Width(colWidths[col]).MaxWidth(colWidths[col]) + } + return h } isSelected := row == selectedVisual if styleOverride != nil { @@ -51,7 +70,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en style = tableSelectedStyle.Foreground(s.GetForeground()) } if col < len(colWidths) && colWidths[col] > 0 { - style = style.Width(colWidths[col]) + style = style.Width(colWidths[col]).MaxWidth(colWidths[col]) } return style } @@ -64,7 +83,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en base = tableSelectedStyle } if col < len(colWidths) && colWidths[col] > 0 { - base = base.Width(colWidths[col]) + base = base.Width(colWidths[col]).MaxWidth(colWidths[col]) } return base }) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 3b0418a..0677643 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -68,6 +68,7 @@ const ( stateLogs stateUsers stateDetail + stateAlertDetail stateFormSite stateFormAlert stateFormUser @@ -384,6 +385,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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": @@ -497,6 +506,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { @@ -818,6 +829,8 @@ func (m Model) View() string { return "" case stateDetail: return m.viewDetailPanel() + case stateAlertDetail: + return m.viewAlertDetailPanel() default: return m.zones.Scan(m.viewDashboard()) } @@ -954,7 +967,7 @@ func (m Model) viewDashboard() string { 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 [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit" + 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: