From 2e07e16b45386306cef3f527e0046b3df390099f Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 16 Jun 2026 19:39:52 -0400 Subject: [PATCH] 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.