Compare commits
4 Commits
de51dde6e6
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
adf8fed44f
|
|||
|
c2bfa5ad82
|
|||
|
2e07e16b45
|
|||
|
dd34da4d67
|
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [v0.1.0] — 2026-06-12
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -74,6 +74,7 @@
|
|||||||
- extract Server type with named handler methods
|
- extract Server type with named handler methods
|
||||||
- split Site into SiteConfig + SiteState
|
- split Site into SiteConfig + SiteState
|
||||||
- unify logging with log/slog
|
- unify logging with log/slog
|
||||||
|
- restructure site form to 2 type-aware pages
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -176,4 +177,7 @@
|
|||||||
- remove tagged scan image in cleanup step
|
- remove tagged scan image in cleanup step
|
||||||
- exclude rc tags from cliff tag_pattern so launch notes span full history
|
- exclude rc tags from cliff tag_pattern so launch notes span full history
|
||||||
- fall back to embedded build info when ldflags absent
|
- fall back to embedded build info when ldflags absent
|
||||||
|
- drop body-grep Security grouping, map polish type in cliff
|
||||||
|
- sync selectedID on click so refreshLive doesn't revert cursor
|
||||||
|
- resolve 4 tag-blocking issues for v0.1.0
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run cmd/uptop/main.go -demo # starts with sample data
|
go run ./cmd/uptop -demo # starts with sample data
|
||||||
ssh -p 23234 localhost # connect to TUI
|
ssh -p 23234 localhost # connect to TUI
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -53,14 +53,14 @@ Canonical repo: [gitea.lerkolabs.com/lerkolabs/uptop](https://gitea.lerkolabs.co
|
|||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run ./cmd/uptop
|
UPTOP_ADMIN_KEY="$(cat ~/.ssh/id_ed25519.pub)" go run ./cmd/uptop
|
||||||
ssh -p 23234 localhost
|
ssh -p 23234 localhost
|
||||||
```
|
```
|
||||||
|
|
||||||
Want some data to look at first:
|
Want some data to look at first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run ./cmd/uptop -demo
|
UPTOP_ADMIN_KEY="$(cat ~/.ssh/id_ed25519.pub)" go run ./cmd/uptop -demo
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ services:
|
|||||||
# LEADER NODE
|
# LEADER NODE
|
||||||
# -------------------------
|
# -------------------------
|
||||||
leader:
|
leader:
|
||||||
build: .
|
image: lerkolabs/uptop:latest
|
||||||
container_name: uptop-leader
|
container_name: uptop-leader
|
||||||
ports:
|
ports:
|
||||||
- "23234:23234" # SSH
|
- "23234:23234" # SSH
|
||||||
@@ -38,7 +38,7 @@ services:
|
|||||||
# FOLLOWER NODE
|
# FOLLOWER NODE
|
||||||
# -------------------------
|
# -------------------------
|
||||||
follower:
|
follower:
|
||||||
build: .
|
image: lerkolabs/uptop:latest
|
||||||
container_name: uptop-follower
|
container_name: uptop-follower
|
||||||
ports:
|
ports:
|
||||||
- "23233:23234" # SSH (Mapped to different host port)
|
- "23233:23234" # SSH (Mapped to different host port)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
# The Application
|
# The Application
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ..
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: uptop-dev
|
container_name: uptop-dev
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
leader:
|
leader:
|
||||||
build: .
|
image: lerkolabs/uptop:latest
|
||||||
environment:
|
environment:
|
||||||
- UPTOP_CLUSTER_MODE=leader
|
- UPTOP_CLUSTER_MODE=leader
|
||||||
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use
|
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use
|
||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
- "23234:23234"
|
- "23234:23234"
|
||||||
|
|
||||||
probe-us-east:
|
probe-us-east:
|
||||||
build: .
|
image: lerkolabs/uptop:latest
|
||||||
environment:
|
environment:
|
||||||
- UPTOP_CLUSTER_MODE=probe
|
- UPTOP_CLUSTER_MODE=probe
|
||||||
- UPTOP_NODE_ID=us-east-1
|
- UPTOP_NODE_ID=us-east-1
|
||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
- leader
|
- leader
|
||||||
|
|
||||||
probe-eu-west:
|
probe-eu-west:
|
||||||
build: .
|
image: lerkolabs/uptop:latest
|
||||||
environment:
|
environment:
|
||||||
- UPTOP_CLUSTER_MODE=probe
|
- UPTOP_CLUSTER_MODE=probe
|
||||||
- UPTOP_NODE_ID=eu-west-1
|
- UPTOP_NODE_ID=eu-west-1
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
image: lerkolabs/uptop:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: uptop
|
container_name: uptop
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
read_only: true
|
read_only: true
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
|
|||||||
alertMap[ea.Name] = ea.ID
|
alertMap[ea.Name] = ea.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextPlaceholderID := -1
|
||||||
desiredAlertNames := make(map[string]bool, len(f.Alerts))
|
desiredAlertNames := make(map[string]bool, len(f.Alerts))
|
||||||
for _, a := range f.Alerts {
|
for _, a := range f.Alerts {
|
||||||
desiredAlertNames[a.Name] = true
|
desiredAlertNames[a.Name] = true
|
||||||
@@ -66,6 +67,9 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
|
|||||||
return changes, fmt.Errorf("create alert %q: %w", a.Name, err)
|
return changes, fmt.Errorf("create alert %q: %w", a.Name, err)
|
||||||
}
|
}
|
||||||
alertMap[a.Name] = id
|
alertMap[a.Name] = id
|
||||||
|
} else {
|
||||||
|
alertMap[a.Name] = nextPlaceholderID
|
||||||
|
nextPlaceholderID--
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alertMap[a.Name] = existing.ID
|
alertMap[a.Name] = existing.ID
|
||||||
@@ -109,6 +113,9 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
|
|||||||
return changes, fmt.Errorf("create group %q: %w", g.Name, err)
|
return changes, fmt.Errorf("create group %q: %w", g.Name, err)
|
||||||
}
|
}
|
||||||
groupMap[g.Name] = id
|
groupMap[g.Name] = id
|
||||||
|
} else {
|
||||||
|
groupMap[g.Name] = nextPlaceholderID
|
||||||
|
nextPlaceholderID--
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
groupMap[g.Name] = existing.ID
|
groupMap[g.Name] = existing.ID
|
||||||
|
|||||||
@@ -266,6 +266,74 @@ func TestApplyDuplicateNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyDryRunNewAlertAndMonitor(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
f := &File{
|
||||||
|
Alerts: []Alert{
|
||||||
|
{Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}},
|
||||||
|
},
|
||||||
|
Monitors: []Monitor{
|
||||||
|
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
changes, err := Apply(context.Background(), s, f, ApplyOpts{DryRun: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dry-run with new alert+monitor should not error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
creates := 0
|
||||||
|
for _, c := range changes {
|
||||||
|
if c.Action == "create" {
|
||||||
|
creates++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if creates != 2 {
|
||||||
|
t.Fatalf("expected 2 creates (alert+monitor), got %d: %+v", creates, changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
sites, _ := s.GetSites(context.Background())
|
||||||
|
alerts, _ := s.GetAllAlerts(context.Background())
|
||||||
|
if len(sites) != 0 {
|
||||||
|
t.Fatalf("dry-run should not persist sites, got %d", len(sites))
|
||||||
|
}
|
||||||
|
if len(alerts) != 0 {
|
||||||
|
t.Fatalf("dry-run should not persist alerts, got %d", len(alerts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyDryRunNewGroupWithChildren(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
f := &File{
|
||||||
|
Alerts: []Alert{
|
||||||
|
{Name: "Slack", Type: "slack", Settings: map[string]string{"url": "https://hooks.example.com"}},
|
||||||
|
},
|
||||||
|
Monitors: []Monitor{
|
||||||
|
{
|
||||||
|
Name: "Prod", Type: "group", Alert: "Slack",
|
||||||
|
Monitors: []Monitor{
|
||||||
|
{Name: "API", Type: "http", URL: "https://api.example.com", Interval: 15, Alert: "Slack"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
changes, err := Apply(context.Background(), s, f, ApplyOpts{DryRun: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dry-run with new group+alert should not error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
creates := 0
|
||||||
|
for _, c := range changes {
|
||||||
|
if c.Action == "create" {
|
||||||
|
creates++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if creates != 3 {
|
||||||
|
t.Fatalf("expected 3 creates (alert+group+child), got %d: %+v", creates, changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyExistingAlertReference(t *testing.T) {
|
func TestApplyExistingAlertReference(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
s.AddAlert(context.Background(), "Existing", "webhook", map[string]string{"url": "https://example.com"})
|
s.AddAlert(context.Background(), "Existing", "webhook", map[string]string{"url": "https://example.com"})
|
||||||
|
|||||||
+163
-138
@@ -326,101 +326,104 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// m.alerts is the tab-data cache (≤5s stale) — no store IO in Update.
|
return m.rebuildSiteForm()
|
||||||
alertOpts := []huh.Option[string]{huh.NewOption("None", "0")}
|
}
|
||||||
|
|
||||||
|
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 {
|
for _, a := range m.alerts {
|
||||||
alertOpts = append(alertOpts, huh.NewOption(
|
alertOpts = append(alertOpts, huh.NewOption(
|
||||||
fmt.Sprintf("%s (%s)", a.Name, a.Type),
|
fmt.Sprintf("%s (%s)", a.Name, a.Type),
|
||||||
strconv.Itoa(a.ID),
|
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 {
|
for _, s := range m.sites {
|
||||||
if s.Type == "group" && s.ID != m.editID {
|
if s.Type == "group" && s.ID != m.editID {
|
||||||
groupOpts = append(groupOpts, huh.NewOption(s.Name, strconv.Itoa(s.ID)))
|
groupOpts = append(groupOpts, huh.NewOption(s.Name, strconv.Itoa(s.ID)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
m.huhForm = huh.NewForm(
|
func (m *Model) buildSiteFormGroups() []*huh.Group {
|
||||||
huh.NewGroup(
|
d := m.siteFormData
|
||||||
huh.NewInput().Title("Monitor Name").
|
alertOpts, groupOpts := m.siteFormOptions()
|
||||||
Placeholder("My Service").
|
|
||||||
Value(&m.siteFormData.Name).
|
// Page 1 — Monitor Setup: core fields + type-specific target
|
||||||
Validate(func(s string) error {
|
setup := []huh.Field{
|
||||||
if s == "" {
|
huh.NewInput().Title("Monitor Name").
|
||||||
return fmt.Errorf("name is required")
|
Placeholder("My Service").
|
||||||
}
|
Value(&d.Name).
|
||||||
return nil
|
Validate(func(s string) error {
|
||||||
}),
|
if s == "" {
|
||||||
huh.NewSelect[string]().Title("Monitor Type").
|
return fmt.Errorf("name is required")
|
||||||
Options(
|
}
|
||||||
huh.NewOption("HTTP/HTTPS", "http"),
|
return nil
|
||||||
huh.NewOption("Push / Heartbeat", "push"),
|
}),
|
||||||
huh.NewOption("Ping (ICMP)", "ping"),
|
huh.NewSelect[string]().Title("Monitor Type").
|
||||||
huh.NewOption("TCP Port", "port"),
|
Options(
|
||||||
huh.NewOption("DNS", "dns"),
|
huh.NewOption("HTTP/HTTPS", "http"),
|
||||||
huh.NewOption("Group", "group"),
|
huh.NewOption("Push / Heartbeat", "push"),
|
||||||
).Value(&m.siteFormData.SiteType),
|
huh.NewOption("Ping (ICMP)", "ping"),
|
||||||
huh.NewSelect[string]().Title("Alert Channel").
|
huh.NewOption("TCP Port", "port"),
|
||||||
Options(alertOpts...).
|
huh.NewOption("DNS", "dns"),
|
||||||
Value(&m.siteFormData.AlertID),
|
huh.NewOption("Group", "group"),
|
||||||
).Title("Monitor Settings"),
|
).Value(&d.SiteType),
|
||||||
huh.NewGroup(
|
huh.NewSelect[string]().Title("Alert Channel").
|
||||||
huh.NewInput().Title("URL").
|
Options(alertOpts...).
|
||||||
Placeholder("https://example.com").
|
Value(&d.AlertID),
|
||||||
Description("Required for HTTP monitors").
|
}
|
||||||
Value(&m.siteFormData.URL).
|
|
||||||
Validate(func(s string) error {
|
switch d.SiteType {
|
||||||
if m.siteFormData.SiteType != "http" {
|
case "http":
|
||||||
return nil
|
setup = append(setup, huh.NewInput().Title("URL").
|
||||||
}
|
Placeholder("https://example.com").
|
||||||
if s == "" {
|
Value(&d.URL).
|
||||||
return fmt.Errorf("URL is required for HTTP monitors")
|
Validate(func(s string) error {
|
||||||
}
|
if s == "" {
|
||||||
u, err := url.Parse(s)
|
return fmt.Errorf("URL is required")
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("invalid URL")
|
u, err := url.Parse(s)
|
||||||
}
|
if err != nil {
|
||||||
if u.Scheme != "http" && u.Scheme != "https" {
|
return fmt.Errorf("invalid URL")
|
||||||
return fmt.Errorf("URL must start with http:// or https://")
|
}
|
||||||
}
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
if u.Host == "" {
|
return fmt.Errorf("URL must start with http:// or https://")
|
||||||
return fmt.Errorf("URL must include a host")
|
}
|
||||||
}
|
if u.Host == "" {
|
||||||
return nil
|
return fmt.Errorf("URL must include a host")
|
||||||
}),
|
}
|
||||||
huh.NewInput().Title("Check Interval (seconds)").
|
return nil
|
||||||
Placeholder("60").
|
}))
|
||||||
Value(&m.siteFormData.Interval).
|
case "ping", "dns":
|
||||||
Validate(func(s string) error {
|
setup = append(setup, huh.NewInput().Title("Hostname / IP").
|
||||||
if m.siteFormData.SiteType == "group" {
|
Placeholder("10.0.0.1").
|
||||||
return nil
|
Value(&d.Hostname))
|
||||||
}
|
case "port":
|
||||||
v, err := strconv.Atoi(s)
|
setup = append(setup,
|
||||||
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").
|
huh.NewInput().Title("Hostname / IP").
|
||||||
Placeholder("10.0.0.1").
|
Placeholder("10.0.0.1").
|
||||||
Description("Target for ping/port/DNS monitors").
|
Value(&d.Hostname),
|
||||||
Value(&m.siteFormData.Hostname),
|
|
||||||
huh.NewInput().Title("Port").
|
huh.NewInput().Title("Port").
|
||||||
Placeholder("0").
|
Placeholder("443").
|
||||||
Description("Target port for TCP port monitors").
|
Value(&d.Port).
|
||||||
Value(&m.siteFormData.Port).
|
|
||||||
Validate(func(s string) error {
|
Validate(func(s string) error {
|
||||||
if m.siteFormData.SiteType != "port" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
v, err := strconv.Atoi(s)
|
v, err := strconv.Atoi(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("must be a number")
|
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 fmt.Errorf("port must be 1-65535")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}),
|
}))
|
||||||
huh.NewInput().Title("Timeout (seconds)").
|
}
|
||||||
Placeholder("5").
|
|
||||||
Value(&m.siteFormData.Timeout).
|
groups := []*huh.Group{huh.NewGroup(setup...).Title("Monitor Setup")}
|
||||||
Validate(func(s string) error {
|
|
||||||
if m.siteFormData.SiteType == "group" {
|
if d.SiteType == "group" {
|
||||||
return nil
|
return groups
|
||||||
}
|
}
|
||||||
v, err := strconv.Atoi(s)
|
|
||||||
if err != nil {
|
// Page 2 — Configuration: type-specific options + shared defaults
|
||||||
return fmt.Errorf("must be a number")
|
var config []huh.Field
|
||||||
}
|
|
||||||
if v < 1 || v > 300 {
|
if d.SiteType == "http" {
|
||||||
return fmt.Errorf("timeout must be 1-300 seconds")
|
config = append(config,
|
||||||
}
|
|
||||||
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").
|
huh.NewSelect[string]().Title("HTTP Method").
|
||||||
Options(
|
Options(
|
||||||
huh.NewOption("GET", "GET"),
|
huh.NewOption("GET", "GET"),
|
||||||
@@ -466,22 +455,75 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
huh.NewOption("DELETE", "DELETE"),
|
huh.NewOption("DELETE", "DELETE"),
|
||||||
huh.NewOption("HEAD", "HEAD"),
|
huh.NewOption("HEAD", "HEAD"),
|
||||||
huh.NewOption("OPTIONS", "OPTIONS"),
|
huh.NewOption("OPTIONS", "OPTIONS"),
|
||||||
).Value(&m.siteFormData.Method),
|
).Value(&d.Method),
|
||||||
huh.NewInput().Title("Accepted Status Codes").
|
huh.NewInput().Title("Accepted Status Codes").
|
||||||
Placeholder("200-299").
|
Placeholder("200-299").
|
||||||
Description("Ranges (200-299) and singles (301) separated by commas").
|
Description("Ranges (200-299) and singles (301) separated by commas").
|
||||||
Value(&m.siteFormData.AcceptedCodes),
|
Value(&d.AcceptedCodes),
|
||||||
).Title("HTTP Settings").WithHideFunc(func() bool {
|
)
|
||||||
return m.siteFormData.SiteType != "http"
|
}
|
||||||
}),
|
|
||||||
huh.NewGroup(
|
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?").
|
huh.NewConfirm().Title("Monitor SSL Certificate?").
|
||||||
Value(&m.siteFormData.CheckSSL),
|
Value(&d.CheckSSL),
|
||||||
huh.NewInput().Title("SSL Warning Threshold (days)").
|
huh.NewInput().Title("SSL Warning Threshold (days)").
|
||||||
Placeholder("7").
|
Placeholder("7").
|
||||||
Value(&m.siteFormData.Threshold).
|
Value(&d.Threshold).
|
||||||
Validate(func(s string) error {
|
Validate(func(s string) error {
|
||||||
if !m.siteFormData.CheckSSL {
|
if !d.CheckSSL {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
v, err := strconv.Atoi(s)
|
v, err := strconv.Atoi(s)
|
||||||
@@ -493,30 +535,13 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
}
|
}
|
||||||
return nil
|
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?").
|
huh.NewConfirm().Title("Ignore TLS Errors?").
|
||||||
Value(&m.siteFormData.IgnoreTLS),
|
Value(&d.IgnoreTLS),
|
||||||
).Title("Advanced").WithHideFunc(func() bool {
|
)
|
||||||
return m.siteFormData.SiteType == "group"
|
}
|
||||||
}),
|
|
||||||
).WithTheme(m.theme.HuhTheme())
|
|
||||||
|
|
||||||
return m.huhForm.Init()
|
groups = append(groups, huh.NewGroup(config...).Title("Configuration"))
|
||||||
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) submitSiteForm() tea.Cmd {
|
func (m *Model) submitSiteForm() tea.Cmd {
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ type Model struct {
|
|||||||
|
|
||||||
huhForm *huh.Form
|
huhForm *huh.Form
|
||||||
siteFormData *siteFormData
|
siteFormData *siteFormData
|
||||||
|
lastSiteType string
|
||||||
alertFormData *alertFormData
|
alertFormData *alertFormData
|
||||||
userFormData *userFormData
|
userFormData *userFormData
|
||||||
maintFormData *maintFormData
|
maintFormData *maintFormData
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if f, ok := form.(*huh.Form); ok {
|
if f, ok := form.(*huh.Form); ok {
|
||||||
m.huhForm = f
|
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 {
|
if m.huhForm.State == huh.StateCompleted {
|
||||||
// The store write runs in the returned Cmd; its writeDoneMsg
|
// The store write runs in the returned Cmd; its writeDoneMsg
|
||||||
// triggers the tab-data reload once the row actually exists.
|
// triggers the tab-data reload once the row actually exists.
|
||||||
@@ -727,6 +734,7 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
for i := m.tableOffset; i < end; i++ {
|
for i := m.tableOffset; i < end; i++ {
|
||||||
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
|
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
|
||||||
m.cursor = i
|
m.cursor = i
|
||||||
|
m.syncSelectedID()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user