From dd34da4d67506ab8653616403b159bc34e7ba634 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 16 Jun 2026 16:58:56 -0400 Subject: [PATCH 1/2] fix(tui): sync selectedID on click so refreshLive doesn't revert cursor handleClick set m.cursor but returned without calling syncSelectedID, causing the next refreshLive tick to snap the cursor back to the previously selected site. --- internal/tui/update.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/tui/update.go b/internal/tui/update.go index e476617..4d7dd20 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -727,6 +727,7 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { for i := m.tableOffset; i < end; i++ { if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) { m.cursor = i + m.syncSelectedID() return m, nil } } -- 2.52.0 From 2e07e16b45386306cef3f527e0046b3df390099f Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 16 Jun 2026 19:39:52 -0400 Subject: [PATCH 2/2] refactor(tui): restructure site form to 2 type-aware pages 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. --- internal/tui/tab_sites.go | 301 +++++++++++++++++++++----------------- internal/tui/tui.go | 1 + internal/tui/update.go | 7 + 3 files changed, 171 insertions(+), 138 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 5b1728a..030f52d 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -326,101 +326,104 @@ func (m *Model) initSiteHuhForm() tea.Cmd { } } - // m.alerts is the tab-data cache (≤5s stale) — no store IO in Update. - alertOpts := []huh.Option[string]{huh.NewOption("None", "0")} + 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")} + 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 +} - 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), +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"). - Description("Target for ping/port/DNS monitors"). - Value(&m.siteFormData.Hostname), + Value(&d.Hostname), huh.NewInput().Title("Port"). - Placeholder("0"). - Description("Target port for TCP port monitors"). - Value(&m.siteFormData.Port). + Placeholder("443"). + Value(&d.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") @@ -429,34 +432,20 @@ func (m *Model) initSiteHuhForm() tea.Cmd { 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( + })) + } + + 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"), @@ -466,22 +455,75 @@ func (m *Model) initSiteHuhForm() tea.Cmd { huh.NewOption("DELETE", "DELETE"), huh.NewOption("HEAD", "HEAD"), huh.NewOption("OPTIONS", "OPTIONS"), - ).Value(&m.siteFormData.Method), + ).Value(&d.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( + 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(&m.siteFormData.CheckSSL), + Value(&d.CheckSSL), huh.NewInput().Title("SSL Warning Threshold (days)"). Placeholder("7"). - Value(&m.siteFormData.Threshold). + Value(&d.Threshold). Validate(func(s string) error { - if !m.siteFormData.CheckSSL { + if !d.CheckSSL { return nil } v, err := strconv.Atoi(s) @@ -493,30 +535,13 @@ func (m *Model) initSiteHuhForm() tea.Cmd { } 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()) + Value(&d.IgnoreTLS), + ) + } - return m.huhForm.Init() + groups = append(groups, huh.NewGroup(config...).Title("Configuration")) + return groups } func (m *Model) submitSiteForm() tea.Cmd { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0b9647f..e7c49e0 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -115,6 +115,7 @@ type Model struct { huhForm *huh.Form siteFormData *siteFormData + lastSiteType string alertFormData *alertFormData userFormData *userFormData maintFormData *maintFormData diff --git a/internal/tui/update.go b/internal/tui/update.go index 4d7dd20..e86582b 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -128,6 +128,13 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) { if f, ok := form.(*huh.Form); ok { m.huhForm = f } + if m.state == stateFormSite && m.siteFormData != nil && + m.siteFormData.SiteType != m.lastSiteType { + rebuildCmd := m.rebuildSiteForm() + // Advance to Type select — user just changed it. + skipName := m.huhForm.NextField() + return m, tea.Batch(rebuildCmd, skipName) + } if m.huhForm.State == huh.StateCompleted { // The store write runs in the returned Cmd; its writeDoneMsg // triggers the tab-data reload once the row actually exists. -- 2.52.0