70a83a1da9
Every Store interface method (except Close) now takes context.Context as first parameter. All 54 db.Query/Exec/QueryRow calls in SQLStore replaced with their *Context variants. DB operations now respect cancellation and deadlines. Context sources by caller: - Engine dbWriter/poll/pruner: engine ctx from Start() - HTTP handlers: r.Context() - config.Apply/Export: caller-provided ctx - TUI/main.go init: context.Background() RunCheck and all sub-checks (HTTP/ping/port/DNS) accept parent ctx. HTTP checks now inherit shutdown cancellation instead of rooting in context.Background(). dbWrite.exec takes ctx so the writer goroutine can cancel stuck DB operations. DeleteSite/ImportData use BeginTx(ctx) instead of Begin().
570 lines
15 KiB
Go
570 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
|
|
|
|
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 == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE" || site.Status == "STALE") && 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// m.alerts is the tab-data cache (≤5s stale) — no store IO in Update.
|
|
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)))
|
|
}
|
|
}
|
|
|
|
m.huhForm = huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewInput().Title("Monitor Name").
|
|
Placeholder("My Service").
|
|
Value(&m.siteFormData.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(&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 != "http" {
|
|
return nil
|
|
}
|
|
if s == "" {
|
|
return fmt.Errorf("URL is required for HTTP monitors")
|
|
}
|
|
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
|
|
}),
|
|
huh.NewInput().Title("Check Interval (seconds)").
|
|
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")
|
|
}
|
|
if v < 5 {
|
|
return fmt.Errorf("minimum interval is 5 seconds")
|
|
}
|
|
return nil
|
|
}),
|
|
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").
|
|
Value(&m.siteFormData.Hostname),
|
|
huh.NewInput().Title("Port").
|
|
Placeholder("0").
|
|
Description("Target port for TCP port monitors").
|
|
Value(&m.siteFormData.Port).
|
|
Validate(func(s string) error {
|
|
if m.siteFormData.SiteType != "port" {
|
|
return nil
|
|
}
|
|
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
|
|
}),
|
|
huh.NewInput().Title("Timeout (seconds)").
|
|
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")
|
|
}
|
|
if v < 1 || v > 300 {
|
|
return fmt.Errorf("timeout must be 1-300 seconds")
|
|
}
|
|
return nil
|
|
}),
|
|
huh.NewInput().Title("Description").
|
|
Placeholder("Optional description").
|
|
Value(&m.siteFormData.Description),
|
|
huh.NewInput().Title("Probe Regions").
|
|
Placeholder("us-east, eu-west (empty = all)").
|
|
Description("Comma-separated regions for distributed probing").
|
|
Value(&m.siteFormData.Regions),
|
|
).Title("Connection").WithHideFunc(func() bool {
|
|
return m.siteFormData.SiteType == "group"
|
|
}),
|
|
huh.NewGroup(
|
|
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(&m.siteFormData.Method),
|
|
huh.NewInput().Title("Accepted Status Codes").
|
|
Placeholder("200-299").
|
|
Description("Ranges (200-299) and singles (301) separated by commas").
|
|
Value(&m.siteFormData.AcceptedCodes),
|
|
).Title("HTTP Settings").WithHideFunc(func() bool {
|
|
return m.siteFormData.SiteType != "http"
|
|
}),
|
|
huh.NewGroup(
|
|
huh.NewConfirm().Title("Monitor SSL Certificate?").
|
|
Value(&m.siteFormData.CheckSSL),
|
|
huh.NewInput().Title("SSL Warning Threshold (days)").
|
|
Placeholder("7").
|
|
Value(&m.siteFormData.Threshold).
|
|
Validate(func(s string) error {
|
|
if !m.siteFormData.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.NewInput().Title("Max Retries Before Alert").
|
|
Placeholder("0").
|
|
Value(&m.siteFormData.Retries).
|
|
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")
|
|
}
|
|
if v < 0 {
|
|
return fmt.Errorf("retries cannot be negative")
|
|
}
|
|
return nil
|
|
}),
|
|
huh.NewConfirm().Title("Ignore TLS Errors?").
|
|
Value(&m.siteFormData.IgnoreTLS),
|
|
).Title("Advanced").WithHideFunc(func() bool {
|
|
return m.siteFormData.SiteType == "group"
|
|
}),
|
|
).WithTheme(m.theme.HuhTheme())
|
|
|
|
return m.huhForm.Init()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
site := models.Site{
|
|
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 {
|
|
// The engine's in-memory config updates immediately; the DB write
|
|
// follows in the Cmd. New sites enter the engine via its poll loop
|
|
// once the insert lands.
|
|
m.engine.UpdateSiteConfig(site)
|
|
return writeCmd("Update site", func() error { return st.UpdateSite(context.Background(), site) })
|
|
}
|
|
return writeCmd("Add site", func() error { return st.AddSite(context.Background(), site) })
|
|
}
|