e12f42fe16
Table columns were computed from terminal width, causing row wrapping when the monitors panel only gets 70% of the space. Introduced contentWidth field set per-tab in viewDashboard. computeLayout, isWide, and renderTable now use contentWidth for column visibility, available space, and max table width calculations. Columns gracefully hide (SSL, RETRIES, TYPE, UPTIME) when the panel is narrower, matching the existing responsive breakpoint behavior.
596 lines
15 KiB
Go
596 lines
15 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/huh"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type siteFormData struct {
|
|
Name string
|
|
SiteType string
|
|
URL string
|
|
Method string
|
|
AcceptedCodes string
|
|
Interval string
|
|
AlertID string
|
|
CheckSSL bool
|
|
Threshold string
|
|
Retries string
|
|
Hostname string
|
|
Port string
|
|
Timeout string
|
|
Description string
|
|
IgnoreTLS bool
|
|
GroupID string
|
|
Regions string
|
|
}
|
|
|
|
type colKey int
|
|
|
|
const (
|
|
colNum colKey = iota
|
|
colName
|
|
colType
|
|
colStatus
|
|
colLatency
|
|
colUptime
|
|
colHistory
|
|
colSSL
|
|
colRetries
|
|
)
|
|
|
|
type columnDef struct {
|
|
key colKey
|
|
wide string
|
|
narrow string
|
|
wideW int
|
|
narrowW int
|
|
minTerm int // minimum terminal width to show (0 = always)
|
|
}
|
|
|
|
var siteColumns = []columnDef{
|
|
{colNum, "#", "#", 4, 4, 0},
|
|
{colName, "NAME", "NAME", 0, 0, 0},
|
|
{colType, "TYPE", "TYPE", 10, 8, mediumBreakpoint},
|
|
{colStatus, "STATUS", "STATUS", 10, 10, 0},
|
|
{colLatency, "LATENCY", "LAT", 10, 7, 0},
|
|
{colUptime, "UPTIME", "UP%", 8, 8, mediumBreakpoint},
|
|
{colHistory, "HISTORY", "HISTORY", 0, 0, mediumBreakpoint},
|
|
{colSSL, "SSL", "SSL", 7, 5, wideBreakpoint},
|
|
{colRetries, "RETRIES", "RT", 9, 5, wideBreakpoint},
|
|
}
|
|
|
|
type tableLayout struct {
|
|
nameW, sparkW int
|
|
headers []string
|
|
colWidths []int
|
|
active []colKey
|
|
}
|
|
|
|
func (m Model) computeLayout() tableLayout {
|
|
wide := m.isWide()
|
|
|
|
var active []colKey
|
|
var headers []string
|
|
var widths []int
|
|
var fixed int
|
|
|
|
cw := m.contentWidth
|
|
if cw == 0 {
|
|
cw = m.termWidth
|
|
}
|
|
for _, c := range siteColumns {
|
|
if c.minTerm > 0 && cw < c.minTerm {
|
|
continue
|
|
}
|
|
active = append(active, c.key)
|
|
if wide {
|
|
headers = append(headers, c.wide)
|
|
widths = append(widths, c.wideW)
|
|
if c.wideW > 0 {
|
|
fixed += c.wideW
|
|
}
|
|
} else {
|
|
headers = append(headers, c.narrow)
|
|
widths = append(widths, c.narrowW)
|
|
if c.narrowW > 0 {
|
|
fixed += c.narrowW
|
|
}
|
|
}
|
|
}
|
|
|
|
numCols := len(headers)
|
|
borderOverhead := 2 + (numCols - 1)
|
|
avail := cw - 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
|
|
|
|
hasHistory := false
|
|
for _, k := range active {
|
|
if k == colHistory {
|
|
hasHistory = true
|
|
break
|
|
}
|
|
}
|
|
|
|
var nameW, sparkW int
|
|
if hasHistory {
|
|
nameW = avail / 2
|
|
sparkW = avail - nameW
|
|
} else {
|
|
nameW = avail
|
|
sparkW = 0
|
|
}
|
|
|
|
if nameW > maxName {
|
|
nameW = maxName
|
|
}
|
|
if nameW < 13 {
|
|
nameW = 13
|
|
}
|
|
if nameW > 35 {
|
|
nameW = 35
|
|
}
|
|
if sparkW > 0 {
|
|
if sparkW < 15 {
|
|
sparkW = 15
|
|
}
|
|
if sparkW > 62 {
|
|
sparkW = 62
|
|
}
|
|
}
|
|
|
|
for i, k := range active {
|
|
if k == colName {
|
|
widths[i] = nameW
|
|
}
|
|
if k == colHistory {
|
|
widths[i] = sparkW
|
|
}
|
|
}
|
|
|
|
return tableLayout{
|
|
nameW: nameW,
|
|
sparkW: sparkW,
|
|
headers: headers,
|
|
colWidths: widths,
|
|
active: active,
|
|
}
|
|
}
|
|
|
|
func pickCols(active []colKey, allCells map[colKey]string) []string {
|
|
row := make([]string, len(active))
|
|
for i, k := range active {
|
|
row[i] = allCells[k]
|
|
}
|
|
return row
|
|
}
|
|
|
|
func (m Model) viewSitesTab() string {
|
|
|
|
if len(m.sites) == 0 {
|
|
return m.emptyState(m.st.titleStyle.Render("uptop")+"\n\nNo monitors configured yet.", "[n] Add your first monitor")
|
|
}
|
|
|
|
layout := m.computeLayout()
|
|
nameW := layout.nameW
|
|
sparkWidth := layout.sparkW - 2
|
|
if sparkWidth < 8 {
|
|
sparkWidth = 8
|
|
}
|
|
if sparkWidth > 60 {
|
|
sparkWidth = 60
|
|
}
|
|
|
|
var groupRows map[int]bool
|
|
return m.renderTable(
|
|
layout.headers,
|
|
len(m.sites),
|
|
func(start, end int) [][]string {
|
|
groupRows = make(map[int]bool)
|
|
var rows [][]string
|
|
for i := start; i < end; i++ {
|
|
site := m.sites[i]
|
|
rowIdx := i - start
|
|
var rowBg lipgloss.TerminalColor
|
|
if i == m.cursor {
|
|
rowBg = m.theme.SelectedBg
|
|
} else if rowIdx%2 == 1 {
|
|
rowBg = m.theme.ZebraBg
|
|
}
|
|
|
|
if site.Type == "group" {
|
|
groupRows[i-start] = true
|
|
icon := typeIcon("group", m.collapsed[site.ID])
|
|
cells := map[colKey]string{
|
|
colNum: strconv.Itoa(i + 1),
|
|
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)),
|
|
colType: "group",
|
|
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
|
colLatency: m.st.subtleStyle.Render("—"),
|
|
colUptime: m.groupUptime(site.ID),
|
|
colHistory: m.groupSparkline(site.ID, sparkWidth, rowBg),
|
|
colSSL: m.st.subtleStyle.Render("-"),
|
|
colRetries: m.st.subtleStyle.Render("—"),
|
|
}
|
|
rows = append(rows, pickCols(layout.active, cells))
|
|
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, nameW-4)
|
|
} else {
|
|
name = limitStr(name, nameW-2)
|
|
}
|
|
|
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
|
nameLen := len([]rune(name))
|
|
errSpace := nameW - nameLen - 3
|
|
if errSpace > 10 {
|
|
cat := classifyError(site.LastError, site.Type, site.StatusCode)
|
|
tag := categoryTag(cat)
|
|
errText := site.LastError
|
|
if tag != "" {
|
|
errText = tag + " " + errText
|
|
}
|
|
name = name + " " + m.st.subtleStyle.Render(limitStr(errText, errSpace))
|
|
}
|
|
}
|
|
|
|
hist, _ := m.engine.GetHistory(site.ID)
|
|
var spark string
|
|
if site.Type == "push" {
|
|
spark = m.heartbeatSparkline(hist.Statuses, sparkWidth, rowBg)
|
|
} else {
|
|
spark = m.latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg)
|
|
}
|
|
|
|
cells := map[colKey]string{
|
|
colNum: strconv.Itoa(i + 1),
|
|
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), name),
|
|
colType: typeIcon(site.Type, false) + " " + site.Type,
|
|
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
|
colLatency: m.fmtLatency(site.Latency),
|
|
colUptime: m.fmtUptime(hist.Statuses),
|
|
colHistory: spark,
|
|
colSSL: m.fmtSSL(site),
|
|
colRetries: m.fmtRetries(site),
|
|
}
|
|
rows = append(rows, pickCols(layout.active, cells))
|
|
}
|
|
return rows
|
|
},
|
|
layout.colWidths,
|
|
func(row, col int) *lipgloss.Style {
|
|
if groupRows[row] {
|
|
s := m.st.siteGroupStyle
|
|
return &s
|
|
}
|
|
return nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func (m *Model) initSiteHuhForm() tea.Cmd {
|
|
m.siteFormData = &siteFormData{
|
|
SiteType: "http",
|
|
Method: "GET",
|
|
AcceptedCodes: "200-299",
|
|
Interval: "60",
|
|
Threshold: "7",
|
|
Retries: "0",
|
|
Timeout: "5",
|
|
Port: "0",
|
|
GroupID: "0",
|
|
}
|
|
|
|
if m.editID > 0 {
|
|
for _, site := range m.sites {
|
|
if site.ID == m.editID {
|
|
m.siteFormData.Name = site.Name
|
|
m.siteFormData.SiteType = site.Type
|
|
m.siteFormData.URL = site.URL
|
|
m.siteFormData.Interval = strconv.Itoa(site.Interval)
|
|
m.siteFormData.AlertID = strconv.Itoa(site.AlertID)
|
|
m.siteFormData.CheckSSL = site.CheckSSL
|
|
m.siteFormData.Threshold = strconv.Itoa(site.ExpiryThreshold)
|
|
m.siteFormData.Retries = strconv.Itoa(site.MaxRetries)
|
|
m.siteFormData.Hostname = site.Hostname
|
|
m.siteFormData.Port = strconv.Itoa(site.Port)
|
|
m.siteFormData.Timeout = strconv.Itoa(site.Timeout)
|
|
m.siteFormData.Description = site.Description
|
|
m.siteFormData.IgnoreTLS = site.IgnoreTLS
|
|
m.siteFormData.GroupID = strconv.Itoa(site.ParentID)
|
|
m.siteFormData.Method = site.Method
|
|
m.siteFormData.AcceptedCodes = site.AcceptedCodes
|
|
m.siteFormData.Regions = site.Regions
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return m.rebuildSiteForm()
|
|
}
|
|
|
|
func (m *Model) rebuildSiteForm() tea.Cmd {
|
|
groups := m.buildSiteFormGroups()
|
|
m.huhForm = huh.NewForm(groups...).WithTheme(m.theme.HuhTheme())
|
|
if m.termWidth > 0 {
|
|
m.huhForm.WithWidth(m.termWidth)
|
|
}
|
|
formHeight := m.termHeight - 7
|
|
if formHeight < 5 {
|
|
formHeight = 5
|
|
}
|
|
m.huhForm.WithHeight(formHeight)
|
|
m.lastSiteType = m.siteFormData.SiteType
|
|
return m.huhForm.Init()
|
|
}
|
|
|
|
func (m *Model) siteFormOptions() (alertOpts, groupOpts []huh.Option[string]) {
|
|
alertOpts = []huh.Option[string]{huh.NewOption("None", "0")}
|
|
for _, a := range m.alerts {
|
|
alertOpts = append(alertOpts, huh.NewOption(
|
|
fmt.Sprintf("%s (%s)", a.Name, a.Type),
|
|
strconv.Itoa(a.ID),
|
|
))
|
|
}
|
|
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)))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (m *Model) buildSiteFormGroups() []*huh.Group {
|
|
d := m.siteFormData
|
|
alertOpts, groupOpts := m.siteFormOptions()
|
|
|
|
// Page 1 — Monitor Setup: core fields + type-specific target
|
|
setup := []huh.Field{
|
|
huh.NewInput().Title("Monitor Name").
|
|
Placeholder("My Service").
|
|
Value(&d.Name).
|
|
Validate(func(s string) error {
|
|
if s == "" {
|
|
return fmt.Errorf("name is required")
|
|
}
|
|
return nil
|
|
}),
|
|
huh.NewSelect[string]().Title("Monitor Type").
|
|
Options(
|
|
huh.NewOption("HTTP/HTTPS", "http"),
|
|
huh.NewOption("Push / Heartbeat", "push"),
|
|
huh.NewOption("Ping (ICMP)", "ping"),
|
|
huh.NewOption("TCP Port", "port"),
|
|
huh.NewOption("DNS", "dns"),
|
|
huh.NewOption("Group", "group"),
|
|
).Value(&d.SiteType),
|
|
huh.NewSelect[string]().Title("Alert Channel").
|
|
Options(alertOpts...).
|
|
Value(&d.AlertID),
|
|
}
|
|
|
|
switch d.SiteType {
|
|
case "http":
|
|
setup = append(setup, huh.NewInput().Title("URL").
|
|
Placeholder("https://example.com").
|
|
Value(&d.URL).
|
|
Validate(func(s string) error {
|
|
if s == "" {
|
|
return fmt.Errorf("URL is required")
|
|
}
|
|
u, err := url.Parse(s)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid URL")
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return fmt.Errorf("URL must start with http:// or https://")
|
|
}
|
|
if u.Host == "" {
|
|
return fmt.Errorf("URL must include a host")
|
|
}
|
|
return nil
|
|
}))
|
|
case "ping", "dns":
|
|
setup = append(setup, huh.NewInput().Title("Hostname / IP").
|
|
Placeholder("10.0.0.1").
|
|
Value(&d.Hostname))
|
|
case "port":
|
|
setup = append(setup,
|
|
huh.NewInput().Title("Hostname / IP").
|
|
Placeholder("10.0.0.1").
|
|
Value(&d.Hostname),
|
|
huh.NewInput().Title("Port").
|
|
Placeholder("443").
|
|
Value(&d.Port).
|
|
Validate(func(s string) error {
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return fmt.Errorf("must be a number")
|
|
}
|
|
if v < 1 || v > 65535 {
|
|
return fmt.Errorf("port must be 1-65535")
|
|
}
|
|
return nil
|
|
}))
|
|
}
|
|
|
|
groups := []*huh.Group{huh.NewGroup(setup...).Title("Monitor Setup")}
|
|
|
|
if d.SiteType == "group" {
|
|
return groups
|
|
}
|
|
|
|
// Page 2 — Configuration: type-specific options + shared defaults
|
|
var config []huh.Field
|
|
|
|
if d.SiteType == "http" {
|
|
config = append(config,
|
|
huh.NewSelect[string]().Title("HTTP Method").
|
|
Options(
|
|
huh.NewOption("GET", "GET"),
|
|
huh.NewOption("POST", "POST"),
|
|
huh.NewOption("PUT", "PUT"),
|
|
huh.NewOption("PATCH", "PATCH"),
|
|
huh.NewOption("DELETE", "DELETE"),
|
|
huh.NewOption("HEAD", "HEAD"),
|
|
huh.NewOption("OPTIONS", "OPTIONS"),
|
|
).Value(&d.Method),
|
|
huh.NewInput().Title("Accepted Status Codes").
|
|
Placeholder("200-299").
|
|
Description("Ranges (200-299) and singles (301) separated by commas").
|
|
Value(&d.AcceptedCodes),
|
|
)
|
|
}
|
|
|
|
config = append(config,
|
|
huh.NewInput().Title("Check Interval (seconds)").
|
|
Placeholder("60").
|
|
Value(&d.Interval).
|
|
Validate(func(s string) error {
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return fmt.Errorf("must be a number")
|
|
}
|
|
if v < 5 {
|
|
return fmt.Errorf("minimum interval is 5 seconds")
|
|
}
|
|
return nil
|
|
}),
|
|
huh.NewInput().Title("Timeout (seconds)").
|
|
Placeholder("5").
|
|
Value(&d.Timeout).
|
|
Validate(func(s string) error {
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return fmt.Errorf("must be a number")
|
|
}
|
|
if v < 1 || v > 300 {
|
|
return fmt.Errorf("timeout must be 1-300 seconds")
|
|
}
|
|
return nil
|
|
}),
|
|
huh.NewInput().Title("Max Retries Before Alert").
|
|
Placeholder("0").
|
|
Value(&d.Retries).
|
|
Validate(func(s string) error {
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return fmt.Errorf("must be a number")
|
|
}
|
|
if v < 0 {
|
|
return fmt.Errorf("retries cannot be negative")
|
|
}
|
|
return nil
|
|
}),
|
|
huh.NewSelect[string]().Title("Parent Group").
|
|
Options(groupOpts...).
|
|
Value(&d.GroupID),
|
|
huh.NewInput().Title("Description").
|
|
Placeholder("Optional description").
|
|
Value(&d.Description),
|
|
huh.NewInput().Title("Probe Regions").
|
|
Placeholder("us-east, eu-west (empty = all)").
|
|
Description("Comma-separated regions for distributed probing").
|
|
Value(&d.Regions),
|
|
)
|
|
|
|
if d.SiteType == "http" {
|
|
config = append(config,
|
|
huh.NewConfirm().Title("Monitor SSL Certificate?").
|
|
Value(&d.CheckSSL),
|
|
huh.NewInput().Title("SSL Warning Threshold (days)").
|
|
Placeholder("7").
|
|
Value(&d.Threshold).
|
|
Validate(func(s string) error {
|
|
if !d.CheckSSL {
|
|
return nil
|
|
}
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return fmt.Errorf("must be a number")
|
|
}
|
|
if v < 1 {
|
|
return fmt.Errorf("threshold must be at least 1 day")
|
|
}
|
|
return nil
|
|
}),
|
|
huh.NewConfirm().Title("Ignore TLS Errors?").
|
|
Value(&d.IgnoreTLS),
|
|
)
|
|
}
|
|
|
|
groups = append(groups, huh.NewGroup(config...).Title("Configuration"))
|
|
return groups
|
|
}
|
|
|
|
func (m *Model) submitSiteForm() tea.Cmd {
|
|
d := m.siteFormData
|
|
interval, _ := strconv.Atoi(d.Interval)
|
|
alertID, _ := strconv.Atoi(d.AlertID)
|
|
threshold, _ := strconv.Atoi(d.Threshold)
|
|
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
|
|
}
|
|
if threshold < 1 {
|
|
threshold = 7
|
|
}
|
|
|
|
cfg := models.SiteConfig{
|
|
ID: m.editID,
|
|
Name: d.Name,
|
|
URL: d.URL,
|
|
Type: d.SiteType,
|
|
Interval: interval,
|
|
AlertID: alertID,
|
|
CheckSSL: d.CheckSSL,
|
|
ExpiryThreshold: threshold,
|
|
MaxRetries: retries,
|
|
Hostname: d.Hostname,
|
|
Port: port,
|
|
Timeout: timeout,
|
|
Description: d.Description,
|
|
IgnoreTLS: d.IgnoreTLS,
|
|
ParentID: groupID,
|
|
Method: d.Method,
|
|
AcceptedCodes: d.AcceptedCodes,
|
|
Regions: d.Regions,
|
|
}
|
|
|
|
st := m.store
|
|
m.state = stateDashboard
|
|
if m.editID > 0 {
|
|
m.engine.UpdateSiteConfig(cfg)
|
|
return writeCmd("Update site", func() error { return st.UpdateSite(context.Background(), cfg) })
|
|
}
|
|
return writeCmd("Add site", func() error { return st.AddSite(context.Background(), cfg) })
|
|
}
|