Files
uptop/internal/tui/tab_sites.go
T
lerko e12f42fe16 fix(tui): use panel width for table layout in split-pane mode
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.
2026-06-20 18:14:01 -04:00

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) })
}