2e07e16b45
Replace 4-page paginated form (17 fields for HTTP) with a 2-page type-aware layout. Page 1 shows core fields + type-specific target (URL for HTTP, Hostname for ping, etc). Page 2 shows configuration with pre-filled defaults. Group type gets 1 page. Form rebuilds dynamically when monitor type changes, preserving all entered values via pointer-bound siteFormData. Focus returns to the Type select after rebuild so users can continue forward. WithWidth set explicitly on rebuild to prevent placeholder truncation.
592 lines
14 KiB
Go
592 lines
14 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
|
|
|
|
for _, c := range siteColumns {
|
|
if c.minTerm > 0 && m.termWidth < 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 := 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
|
|
|
|
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.Color
|
|
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) })
|
|
}
|