From 3da9fdc9b5600ad696e885e06f391fa8d985de16 Mon Sep 17 00:00:00 2001 From: Fawaz-Abu-Abdel Date: Sat, 9 May 2026 23:02:46 +0300 Subject: [PATCH 1/3] feat(tui): add interactive shortcuts and visual feedback indicators - Implement 'r' for force refreshing health checks for all targets. - Implement 'f' for toggling focus between target list and details/logs. - Add visual "Refreshing..." indicator and "Last Check" timestamp to the UI. - Support case-insensitive shortcuts (r/R, f/F, l/L) and Ctrl-key variants. - Update UI legend in the search widget to display available shortcuts. - Enhance Escape key to reset focus from logs back to the target list. --- tui/layout.go | 4 ++-- tui/logs.go | 2 +- tui/manager.go | 53 +++++++++++++++++++++++++++++++++++++++++++++-- tui/monitoring.go | 44 +++++++++++++++++++++++++++++++++------ tui/widgets.go | 2 +- 5 files changed, 93 insertions(+), 12 deletions(-) diff --git a/tui/layout.go b/tui/layout.go index 94c6089..aff884a 100644 --- a/tui/layout.go +++ b/tui/layout.go @@ -45,7 +45,7 @@ func (m *Manager) initializeMultiTargetWidgets() { m.searchWidget.Title = "Search" m.searchWidget.TitleStyle.Fg = ui.ColorWhite m.searchWidget.TitleStyle.Modifier = ui.ModifierBold - m.searchWidget.Text = "Press / to activate" + m.searchWidget.Text = " [/] Search [r] Refresh [l] Logs [f] Focus [q] Quit" m.searchWidget.TextStyle.Fg = ui.ColorWhite m.listWidget = uw.NewFilteredList() @@ -90,7 +90,7 @@ func (m *Manager) handleSearchChange(query string, filteredIndices []int) { } } } else { - m.searchWidget.Text = "Press / to activate" + m.searchWidget.Text = " [/] Search [r] Refresh [l] Logs [f] Focus [q] Quit" m.searchWidget.TextStyle.Fg = ui.ColorWhite m.searchWidget.BorderStyle.Fg = ui.ColorCyan diff --git a/tui/logs.go b/tui/logs.go index c2fed6e..5938c6b 100644 --- a/tui/logs.go +++ b/tui/logs.go @@ -383,7 +383,7 @@ func (m *Manager) ToggleLogsVisibility() { m.detailsManager.ActiveGrid = m.detailsManager.LogsGrid m.detailsManager.LogsWidget.BorderStyle.Fg = ui.ColorGreen - m.detailsManager.LogsWidget.Title = "Recent Logs (FOCUSED) - ↑↓:nav Enter:expand l:hide" + m.detailsManager.LogsWidget.Title = "Recent Logs (FOCUSED) - [r] Refresh [l] Hide [f] Focus [q] Quit" keys := m.getKeysForCurrentSelection() if len(keys) > 0 { diff --git a/tui/manager.go b/tui/manager.go index 792e76f..5ccad5a 100644 --- a/tui/manager.go +++ b/tui/manager.go @@ -483,8 +483,8 @@ func (m *Manager) UpdateTarget(data TargetData) { currentKey := m.getCurrentTargetKey() if currentKey != nil && currentKey.String() == targetKeyStr { - if m.isSingle && m.detailsManager.URLWidget != nil { - m.detailsManager.URLWidget.Text = data.Target.URL + if m.detailsManager.URLWidget != nil { + m.detailsManager.URLWidget.Text = fmt.Sprintf("%s\n[Last: %s](fg:cyan)", data.Target.URL, data.Result.LastCheckTime.Format("15:04:05")) } m.restorePlotData(targetKeyStr) m.updateCurrentTargetWidgets(data.Result, data.Stats) @@ -628,3 +628,52 @@ func (m *Manager) updatePlotDataForTarget(targetName string, result net.WebsiteC m.plotData[targetName] = history } +func (m *Manager) ShowRefreshing() { + if m.detailsManager.URLWidget != nil { + originalTitle := m.detailsManager.URLWidget.Title + m.detailsManager.URLWidget.Title = "Refreshing..." + m.detailsManager.URLWidget.TitleStyle.Fg = ui.ColorYellow + ui.Render(m.grid) + + go func() { + time.Sleep(500 * time.Millisecond) + m.detailsManager.URLWidget.Title = originalTitle + m.detailsManager.URLWidget.TitleStyle.Fg = ui.ColorWhite + ui.Render(m.grid) + }() + } +} + +func (m *Manager) ToggleFocus() { + if m.isSingle { + if m.detailsManager.URLWidget != nil { + originalFg := m.detailsManager.URLWidget.BorderStyle.Fg + m.detailsManager.URLWidget.BorderStyle.Fg = ui.ColorYellow + ui.Render(m.grid) + go func() { + time.Sleep(200 * time.Millisecond) + m.detailsManager.URLWidget.BorderStyle.Fg = originalFg + ui.Render(m.grid) + }() + } + return + } + + m.focusOnLogs = !m.focusOnLogs + + if m.focusOnLogs { + if m.listWidget != nil { + m.listWidget.BorderStyle.Fg = ui.ColorCyan + } + if m.detailsManager.LogsWidget != nil { + m.detailsManager.LogsWidget.BorderStyle.Fg = ui.ColorGreen + } + } else { + if m.listWidget != nil { + m.listWidget.BorderStyle.Fg = ui.ColorGreen + } + if m.detailsManager.LogsWidget != nil { + m.detailsManager.LogsWidget.BorderStyle.Fg = ui.ColorCyan + } + } +} diff --git a/tui/monitoring.go b/tui/monitoring.go index 5f152e1..5a9bbd8 100644 --- a/tui/monitoring.go +++ b/tui/monitoring.go @@ -109,12 +109,17 @@ func StartMonitoring(targets []config.Target, options Options) { dataChannel := make(chan TargetData, len(targets)*_dataChannelMultiplier) var wg sync.WaitGroup + refreshChans := make([]chan struct{}, len(targets)) + for i := range refreshChans { + refreshChans[i] = make(chan struct{}, 1) + } + for i, target := range targets { wg.Add(1) - go func(t config.Target, index int) { + go func(t config.Target, index int, rChan chan struct{}) { defer wg.Done() - monitorTargetTUI(ctx, t, index, monitors, sequences, alertStates, webhookAlertStates, dataChannel, options) - }(target, i) + monitorTargetTUI(ctx, t, index, monitors, sequences, alertStates, webhookAlertStates, dataChannel, options, rChan) + }(target, i, refreshChans[i]) } go func() { @@ -191,9 +196,30 @@ func StartMonitoring(targets []config.Target, options Options) { ui.Render(manager.grid) } } - case "l": + case "r", "R", "": if manager.listWidget != nil && manager.listWidget.IsSearchMode() { - manager.listWidget.UpdateSearch("l") + manager.listWidget.UpdateSearch(e.ID) + } else { + manager.ShowRefreshing() + for _, ch := range refreshChans { + select { + case ch <- struct{}{}: + default: + } + } + } + + ui.Render(manager.grid) + case "f", "F", "": + if manager.listWidget != nil && manager.listWidget.IsSearchMode() { + manager.listWidget.UpdateSearch(e.ID) + } else { + manager.ToggleFocus() + } + ui.Render(manager.grid) + case "l", "L": + if manager.listWidget != nil && manager.listWidget.IsSearchMode() { + manager.listWidget.UpdateSearch(e.ID) } else { manager.ToggleLogsVisibility() } @@ -214,7 +240,11 @@ func StartMonitoring(targets []config.Target, options Options) { manager.listWidget.OnSearchChange(manager.listWidget.GetQuery(), manager.listWidget.GetFilteredIndices()) } ui.Render(manager.grid) + } else if manager.IsFocusedOnLogs() { + manager.ToggleFocus() + ui.Render(manager.grid) } + case _backspaceKey, _ctrlBackspace, "": if manager.listWidget != nil && manager.listWidget.IsSearchMode() { manager.listWidget.UpdateSearch(e.ID) @@ -261,7 +291,7 @@ func StartMonitoring(targets []config.Target, options Options) { } } -func monitorTargetTUI(ctx context.Context, target config.Target, targetIndex int, monitors map[string]*stats.Monitor, sequences map[string]*int, alertStates map[string]*bool, webhookAlertStates map[string]*bool, dataChannel chan<- TargetData, options Options) { +func monitorTargetTUI(ctx context.Context, target config.Target, targetIndex int, monitors map[string]*stats.Monitor, sequences map[string]*int, alertStates map[string]*bool, webhookAlertStates map[string]*bool, dataChannel chan<- TargetData, options Options, refreshChan chan struct{}) { ticker := time.NewTicker(target.GetRefreshInterval()) defer ticker.Stop() @@ -443,6 +473,8 @@ func monitorTargetTUI(ctx context.Context, target config.Target, targetIndex int select { case <-ctx.Done(): return + case <-refreshChan: + makeRequest() case <-ticker.C: makeRequest() if options.Count > 0 && attemptCount >= options.Count { diff --git a/tui/widgets.go b/tui/widgets.go index e42f218..a6fa801 100644 --- a/tui/widgets.go +++ b/tui/widgets.go @@ -42,7 +42,7 @@ func NewDetailsManager() *DetailsManager { func (m *DetailsManager) InitializeWidgets(url string, refreshInterval time.Duration) { m.QuitWidget = widgets.NewParagraph() m.QuitWidget.Title = "Information" - m.QuitWidget.Text = "q:quit l:logs ↑↓:nav" + m.QuitWidget.Text = "[r] Refresh [l] Logs [f] Focus [q] Quit" m.QuitWidget.BorderStyle.Fg = ui.ColorClear m.UptimeWidget = widgets.NewParagraph() From 12c604a48e22880f6937a23a99c240611be67180 Mon Sep 17 00:00:00 2001 From: Fawaz-Abu-Abdel <160165735+Fawaz-Abu-Abdel@users.noreply.github.com> Date: Tue, 26 May 2026 23:47:04 +0000 Subject: [PATCH 2/3] fix: address golangci-lint goconst errors and Node.js 20 deprecation - Centralized common test constants in metrics package to metrics/common_test.go - Replaced repeated string literals with constants in metrics and config tests - Updated GitHub Actions workflow to use Node.js 24 by setting FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true --- .github/workflows/release.yml | 3 ++ config/config_test.go | 66 ++++++++++++++++++++--------------- metrics/client_test.go | 21 +++++------ metrics/common_test.go | 12 +++++++ metrics/mapping_test.go | 32 ++++++++--------- 5 files changed, 77 insertions(+), 57 deletions(-) create mode 100644 metrics/common_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de0f8e9..0d928fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,9 @@ on: permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: lint: name: Lint diff --git a/config/config_test.go b/config/config_test.go index 6c7692c..ba6f3e1 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -8,6 +8,16 @@ import ( "github.com/Owloops/updo/net" ) +const ( + testURL = "https://example.com" + googleURL = "https://google.com" + githubURL = "https://github.com" + unnamedURL = "https://unnamed.com" + exampleStr = "Example" + googleStr = "Google" + githubStr = "GitHub" +) + func TestLoadConfig(t *testing.T) { configContent := ` [global] @@ -58,11 +68,11 @@ method = "POST" } target := config.Targets[0] - if target.URL != "https://example.com" { - t.Errorf("Expected URL=https://example.com, got %s", target.URL) + if target.URL != testURL { + t.Errorf("Expected URL=%s, got %s", testURL, target.URL) } - if target.Name != "Example" { - t.Errorf("Expected Name=Example, got %s", target.Name) + if target.Name != exampleStr { + t.Errorf("Expected Name=%s, got %s", exampleStr, target.Name) } if target.RefreshInterval != 60 { t.Errorf("Expected RefreshInterval=60, got %d", target.RefreshInterval) @@ -328,10 +338,10 @@ func TestGlobalGetMethods(t *testing.T) { func TestFilterTargets(t *testing.T) { config := &Config{ Targets: []Target{ - {URL: "https://example.com", Name: "Example"}, - {URL: "https://google.com", Name: "Google"}, - {URL: "https://github.com", Name: "GitHub"}, - {URL: "https://unnamed.com"}, + {URL: testURL, Name: exampleStr}, + {URL: googleURL, Name: googleStr}, + {URL: githubURL, Name: githubStr}, + {URL: unnamedURL}, }, } @@ -348,29 +358,29 @@ func TestFilterTargets(t *testing.T) { }, { name: "only by name", - only: []string{"Example"}, + only: []string{exampleStr}, expected: 1, - expectURL: "https://example.com", + expectURL: testURL, }, { name: "only by URL", - only: []string{"https://google.com"}, + only: []string{googleURL}, expected: 1, - expectURL: "https://google.com", + expectURL: googleURL, }, { name: "skip by name", - skip: []string{"Google"}, + skip: []string{googleStr}, expected: 3, }, { name: "skip by URL for unnamed target", - skip: []string{"https://unnamed.com"}, + skip: []string{unnamedURL}, expected: 3, }, { name: "only multiple", - only: []string{"Example", "GitHub"}, + only: []string{exampleStr, githubStr}, expected: 2, }, } @@ -398,13 +408,13 @@ func TestGetTargetName(t *testing.T) { }{ { name: "with name", - target: Target{Name: "Example", URL: "https://example.com"}, - expected: "Example", + target: Target{Name: exampleStr, URL: testURL}, + expected: exampleStr, }, { name: "without name", - target: Target{URL: "https://example.com"}, - expected: "https://example.com", + target: Target{URL: testURL}, + expected: testURL, }, } @@ -428,23 +438,23 @@ func TestContainsTarget(t *testing.T) { }{ { name: "found by target name", - list: []string{"Example", "Google"}, - target: "Example", - url: "https://example.com", + list: []string{exampleStr, googleStr}, + target: exampleStr, + url: testURL, expected: true, }, { name: "found by URL", - list: []string{"Example", "https://google.com"}, - target: "Google", - url: "https://google.com", + list: []string{exampleStr, googleURL}, + target: googleStr, + url: googleURL, expected: true, }, { name: "not found", - list: []string{"Example", "Google"}, - target: "GitHub", - url: "https://github.com", + list: []string{exampleStr, googleStr}, + target: githubStr, + url: githubURL, expected: false, }, } diff --git a/metrics/client_test.go b/metrics/client_test.go index e36bc99..a7fa04f 100644 --- a/metrics/client_test.go +++ b/metrics/client_test.go @@ -13,11 +13,6 @@ import ( "github.com/Owloops/updo/net" ) -const ( - testUser = "user" - testPass = "pass" -) - func TestWriteClient(t *testing.T) { cfg := NewConfig() client := NewWriteClient(cfg) @@ -27,7 +22,7 @@ func TestWriteClient(t *testing.T) { t.Fatal("WriteClient not properly initialized") } - target := config.Target{Name: "test", URL: "https://example.com"} + target := config.Target{Name: "test", URL: testURL} result := net.WebsiteCheckResult{URL: target.URL, IsUp: true, StatusCode: 200} initialCount := len(client.samples) @@ -49,7 +44,7 @@ func TestWriteClient(t *testing.T) { func TestSSLExpiry(t *testing.T) { client := NewWriteClient(NewConfig()) - target := config.Target{Name: "ssl-test", URL: "https://example.com"} + target := config.Target{Name: "ssl-test", URL: testURL} tests := []struct { days int @@ -72,17 +67,17 @@ func TestNormalizeTimestamps(t *testing.T) { client := NewWriteClient(NewConfig()) samples := []*prompb.TimeSeries{ { - Labels: []*prompb.Label{{Name: "__name__", Value: "test1"}}, + Labels: []*prompb.Label{{Name: _nameLbl, Value: "test1"}}, Samples: []*prompb.Sample{{Timestamp: 0, Value: 1.0}, {Timestamp: 0, Value: 2.0}}, }, { - Labels: []*prompb.Label{{Name: "__name__", Value: "test2"}}, + Labels: []*prompb.Label{{Name: _nameLbl, Value: "test2"}}, Samples: []*prompb.Sample{{Timestamp: 0, Value: 3.0}}, }, nil, - {Labels: []*prompb.Label{{Name: "__name__", Value: "empty"}}, Samples: []*prompb.Sample{}}, + {Labels: []*prompb.Label{{Name: _nameLbl, Value: emptyStr}}, Samples: []*prompb.Sample{}}, { - Labels: []*prompb.Label{{Name: "__name__", Value: "mixed"}}, + Labels: []*prompb.Label{{Name: _nameLbl, Value: "mixed"}}, Samples: []*prompb.Sample{nil, {Timestamp: 0, Value: 4.0}}, }, } @@ -112,7 +107,7 @@ func TestNormalizeTimestamps(t *testing.T) { func TestConcurrentAccess(t *testing.T) { client := NewWriteClient(NewConfig()) - target := config.Target{Name: "concurrent", URL: "https://example.com"} + target := config.Target{Name: "concurrent", URL: testURL} result := net.WebsiteCheckResult{URL: target.URL, IsUp: true} var wg sync.WaitGroup @@ -143,7 +138,7 @@ func TestHTTPRequests(t *testing.T) { header bool expect bool }{ - {"success", 200, "", false, false, true}, + {successStr, 200, "", false, false, true}, {"error", 400, "out of order", false, false, false}, {"auth", 200, "", true, false, true}, {"headers", 200, "", false, true, true}, diff --git a/metrics/common_test.go b/metrics/common_test.go new file mode 100644 index 0000000..f60623c --- /dev/null +++ b/metrics/common_test.go @@ -0,0 +1,12 @@ +package metrics + +const ( + testURL = "https://example.com" + apiURL = "https://api.com" + brokenURL = "https://broken.com" + secureURL = "https://secure.com" + emptyStr = "empty" + successStr = "success" + testUser = "user" + testPass = "pass" +) diff --git a/metrics/mapping_test.go b/metrics/mapping_test.go index 04761ca..543d36d 100644 --- a/metrics/mapping_test.go +++ b/metrics/mapping_test.go @@ -10,11 +10,11 @@ import ( ) func TestMapTargetLabels(t *testing.T) { - target := config.Target{Name: "service", URL: "https://example.com"} + target := config.Target{Name: "service", URL: testURL} result := net.WebsiteCheckResult{URL: target.URL, IsUp: true, StatusCode: 200} labels := MapTargetLabels(target, result, "us-east-1") - expected := map[string]string{"name": "service", "url": "https://example.com", "region": "us-east-1"} + expected := map[string]string{"name": "service", "url": testURL, "region": "us-east-1"} for key, want := range expected { if got := labels[key]; got != want { @@ -24,7 +24,7 @@ func TestMapTargetLabels(t *testing.T) { } func TestMapSeries(t *testing.T) { - labels := map[string]string{"name": "test", "url": "https://example.com", "": "empty", "empty": ""} + labels := map[string]string{"name": "test", "url": testURL, "": emptyStr, emptyStr: ""} pbLabels := MapSeries("target_up", labels) if len(pbLabels) != 3 { @@ -35,12 +35,12 @@ func TestMapSeries(t *testing.T) { for _, label := range pbLabels { labelMap[label.Name] = label.Value if label.Name == "" || label.Value == "" { - t.Error("Found empty label after filtering") + t.Errorf("Found %s label after filtering", emptyStr) } } - if labelMap["__name__"] != "updo_target_up" { - t.Errorf("Expected __name__=updo_target_up, got %s", labelMap["__name__"]) + if labelMap[_nameLbl] != "updo_target_up" { + t.Errorf("Expected %s=updo_target_up, got %s", _nameLbl, labelMap[_nameLbl]) } for i := 1; i < len(pbLabels); i++ { @@ -59,20 +59,20 @@ func TestConvertCheckToTimeSeries(t *testing.T) { }{ { "up_target", - config.Target{Name: "test", URL: "https://example.com"}, - net.WebsiteCheckResult{URL: "https://example.com", IsUp: true, StatusCode: 200, ResponseTime: 100 * time.Millisecond}, + config.Target{Name: "test", URL: testURL}, + net.WebsiteCheckResult{URL: testURL, IsUp: true, StatusCode: 200, ResponseTime: 100 * time.Millisecond}, map[string]float64{"target_up": 1.0, "response_time_seconds": 0.1, "http_status_code_total": 1.0}, }, { "down_target", - config.Target{Name: "failing", URL: "https://broken.com"}, - net.WebsiteCheckResult{URL: "https://broken.com", IsUp: false, StatusCode: 500}, + config.Target{Name: "failing", URL: brokenURL}, + net.WebsiteCheckResult{URL: brokenURL, IsUp: false, StatusCode: 500}, map[string]float64{"target_up": 0.0, "http_status_code_total": 1.0}, }, { "with_assertion", - config.Target{Name: "assert", URL: "https://api.com", AssertText: "success"}, - net.WebsiteCheckResult{URL: "https://api.com", IsUp: true, StatusCode: 200, AssertText: "success", AssertionPassed: true}, + config.Target{Name: "assert", URL: apiURL, AssertText: successStr}, + net.WebsiteCheckResult{URL: apiURL, IsUp: true, StatusCode: 200, AssertText: successStr, AssertionPassed: true}, map[string]float64{"target_up": 1.0, "assertion_passed": 1.0, "http_status_code_total": 1.0}, }, } @@ -105,7 +105,7 @@ func TestConvertCheckToTimeSeries(t *testing.T) { } func TestConvertWithTraceInfo(t *testing.T) { - target := config.Target{Name: "traced", URL: "https://example.com"} + target := config.Target{Name: "traced", URL: testURL} result := net.WebsiteCheckResult{ URL: target.URL, IsUp: true, StatusCode: 200, TraceInfo: &net.HttpTraceInfo{ @@ -120,7 +120,7 @@ func TestConvertWithTraceInfo(t *testing.T) { found := make(map[string]bool) for _, series := range timeSeries { for _, label := range series.Labels { - if label.Name == "__name__" { + if label.Name == _nameLbl { metricName := strings.TrimPrefix(label.Value, "updo_") found[metricName] = true } @@ -135,7 +135,7 @@ func TestConvertWithTraceInfo(t *testing.T) { } func TestConvertSSLExpiryToTimeSeries(t *testing.T) { - target := config.Target{Name: "ssl-test", URL: "https://secure.com"} + target := config.Target{Name: "ssl-test", URL: secureURL} tests := []struct { days int expect bool @@ -158,7 +158,7 @@ func TestConvertSSLExpiryToTimeSeries(t *testing.T) { var foundSSLMetric bool for _, label := range result.Labels { - if label.Name == "__name__" && strings.Contains(label.Value, "ssl_cert_expiry_days") { + if label.Name == _nameLbl && strings.Contains(label.Value, "ssl_cert_expiry_days") { foundSSLMetric = true } } From 93c287f6400c58e1210c4f6a26c11f247314a59e Mon Sep 17 00:00:00 2001 From: Fawaz-Abu-Abdel <160165735+Fawaz-Abu-Abdel@users.noreply.github.com> Date: Tue, 26 May 2026 23:57:35 +0000 Subject: [PATCH 3/3] fix: address all golangci-lint goconst errors and Node.js 20 deprecation - Centralized common test constants in metrics and notifications packages - Replaced repeated string literals with constants in metrics, config, net, notifications, and stats tests - Used existing _eventTargetDown and _eventTargetUp constants in notifications package - Updated GitHub Actions workflow to use Node.js 24 by setting FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true --- config/config_test.go | 21 +++++++----- net/net_test.go | 56 ++++++++++++++++++-------------- notifications/common_test.go | 9 +++++ notifications/formatters_test.go | 48 +++++++++++++-------------- notifications/webhook.go | 4 +-- notifications/webhook_test.go | 38 +++++++++++----------- stats/targets_test.go | 52 +++++++++++++++-------------- 7 files changed, 126 insertions(+), 102 deletions(-) create mode 100644 notifications/common_test.go diff --git a/config/config_test.go b/config/config_test.go index ba6f3e1..1866027 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -13,6 +13,9 @@ const ( googleURL = "https://google.com" githubURL = "https://github.com" unnamedURL = "https://unnamed.com" + overrideURL = "https://override.example.com" + inheritURL = "https://inherit.example.com" + unlimitedURL = "https://unlimited.example.com" exampleStr = "Example" googleStr = "Google" githubStr = "GitHub" @@ -27,8 +30,8 @@ follow_redirects = false receive_alert = false [[targets]] -url = "https://example.com" -name = "Example" +url = "` + testURL + `" +name = "` + exampleStr + `" refresh_interval = 60 timeout = 20 method = "POST" @@ -94,7 +97,7 @@ receive_alert = true skip_ssl = true [[targets]] -url = "https://override.example.com" +url = "` + overrideURL + `" name = "Override" follow_redirects = false accept_redirects = false @@ -102,7 +105,7 @@ receive_alert = false skip_ssl = false [[targets]] -url = "https://inherit.example.com" +url = "` + inheritURL + `" name = "Inherit" ` @@ -172,16 +175,16 @@ func TestBodySizeLimitInheritance(t *testing.T) { body_size_limit = 2097152 [[targets]] -url = "https://inherit.example.com" +url = "` + inheritURL + `" name = "Inherit" [[targets]] -url = "https://override.example.com" +url = "` + overrideURL + `" name = "Override" body_size_limit = 524288 [[targets]] -url = "https://unlimited.example.com" +url = "` + unlimitedURL + `" name = "Unlimited" body_size_limit = 0 ` @@ -228,7 +231,7 @@ body_size_limit = 0 func TestBodySizeLimitDefault(t *testing.T) { configContent := ` [[targets]] -url = "https://example.com" +url = "` + testURL + `" ` tmpFile, err := os.CreateTemp("", "test-config-bodysize-default-*.toml") @@ -264,7 +267,7 @@ url = "https://example.com" func TestLoadConfigDefaults(t *testing.T) { configContent := ` [[targets]] -url = "https://example.com" +url = "` + testURL + `" ` tmpFile, err := os.CreateTemp("", "test-config-defaults-*.toml") diff --git a/net/net_test.go b/net/net_test.go index efd11c8..e076ffb 100644 --- a/net/net_test.go +++ b/net/net_test.go @@ -7,6 +7,14 @@ import ( "time" ) +const ( + exampleCom = "example.com" + httpExample = "http://example.com" + httpsExample = "https://example.com" + httpsGoogle = "https://google.com" + movedPerm = "Moved Permanently" +) + func TestIsUrl(t *testing.T) { tests := []struct { name string @@ -15,12 +23,12 @@ func TestIsUrl(t *testing.T) { }{ { name: "valid domain", - input: "https://example.com", + input: httpsExample, want: true, }, { name: "domain with port", - input: "https://example.com:8080", + input: httpsExample + ":8080", want: true, }, { @@ -55,7 +63,7 @@ func TestIsUrl(t *testing.T) { }, { name: "missing protocol", - input: "example.com", + input: exampleCom, want: false, }, { @@ -93,7 +101,7 @@ func TestIsIPAddress(t *testing.T) { }, { name: "domain name", - input: "https://example.com", + input: httpsExample, want: false, }, { @@ -122,15 +130,15 @@ func TestAutoDetectProtocol(t *testing.T) { }{ { name: "add protocol to domain", - input: "example.com", - wantHTTPS: "https://example.com", - wantHTTP: "http://example.com", + input: exampleCom, + wantHTTPS: httpsExample, + wantHTTP: httpExample, }, { name: "preserve existing protocol", - input: "https://google.com", - wantHTTPS: "https://google.com", - wantHTTP: "https://google.com", + input: httpsGoogle, + wantHTTPS: httpsGoogle, + wantHTTP: httpsGoogle, }, { name: "add protocol to IP with port", @@ -164,18 +172,18 @@ func TestFormatURL(t *testing.T) { }{ { name: "add https to domain", - input: "example.com", - want: "https://example.com", + input: exampleCom, + want: httpsExample, }, { name: "preserve existing protocol", - input: "http://example.com", - want: "http://example.com", + input: httpExample, + want: httpExample, }, { name: "trim whitespace", - input: " example.com ", - want: "https://example.com", + input: " " + exampleCom + " ", + want: httpsExample, }, { name: "localhost with port", @@ -249,7 +257,7 @@ func TestCheckWebsite(t *testing.T) { { name: "redirect without accept redirects", statusCode: 301, - responseBody: "Moved Permanently", + responseBody: movedPerm, config: NetworkConfig{AcceptRedirects: false, Timeout: 5 * time.Second}, expectSuccess: false, expectAssertion: true, @@ -257,7 +265,7 @@ func TestCheckWebsite(t *testing.T) { { name: "redirect with accept redirects", statusCode: 301, - responseBody: "Moved Permanently", + responseBody: movedPerm, config: NetworkConfig{AcceptRedirects: true, Timeout: 5 * time.Second}, expectSuccess: true, expectAssertion: true, @@ -281,7 +289,7 @@ func TestCheckWebsite(t *testing.T) { { name: "redirect with accept redirects and assertion failure", statusCode: 301, - responseBody: "Moved Permanently", + responseBody: movedPerm, config: NetworkConfig{AcceptRedirects: true, AssertText: "Not Found", Timeout: 5 * time.Second}, expectSuccess: false, expectAssertion: false, @@ -289,7 +297,7 @@ func TestCheckWebsite(t *testing.T) { { name: "redirect with accept redirects and should fail", statusCode: 301, - responseBody: "Moved Permanently", + responseBody: movedPerm, config: NetworkConfig{AcceptRedirects: true, ShouldFail: true, Timeout: 5 * time.Second}, expectSuccess: false, expectAssertion: true, @@ -297,7 +305,7 @@ func TestCheckWebsite(t *testing.T) { { name: "redirect follow false accept false", statusCode: 301, - responseBody: "Moved Permanently", + responseBody: movedPerm, config: NetworkConfig{FollowRedirects: false, AcceptRedirects: false, Timeout: 5 * time.Second}, expectSuccess: false, expectAssertion: true, @@ -305,7 +313,7 @@ func TestCheckWebsite(t *testing.T) { { name: "redirect follow false accept true", statusCode: 301, - responseBody: "Moved Permanently", + responseBody: movedPerm, config: NetworkConfig{FollowRedirects: false, AcceptRedirects: true, Timeout: 5 * time.Second}, expectSuccess: true, expectAssertion: true, @@ -313,7 +321,7 @@ func TestCheckWebsite(t *testing.T) { { name: "redirect follow true accept false", statusCode: 301, - responseBody: "Moved Permanently", + responseBody: movedPerm, config: NetworkConfig{FollowRedirects: true, AcceptRedirects: false, Timeout: 5 * time.Second}, expectSuccess: false, expectAssertion: true, @@ -321,7 +329,7 @@ func TestCheckWebsite(t *testing.T) { { name: "redirect follow true accept true", statusCode: 301, - responseBody: "Moved Permanently", + responseBody: movedPerm, config: NetworkConfig{FollowRedirects: true, AcceptRedirects: true, Timeout: 5 * time.Second}, expectSuccess: true, expectAssertion: true, diff --git a/notifications/common_test.go b/notifications/common_test.go new file mode 100644 index 0000000..f111263 --- /dev/null +++ b/notifications/common_test.go @@ -0,0 +1,9 @@ +package notifications + +const ( + testURL = "https://example.com" + testSite = "Test Site" + slackFormatter = "*notifications.SlackFormatter" + discordFormatter = "*notifications.DiscordFormatter" + genericFormatter = "*notifications.GenericFormatter" +) diff --git a/notifications/formatters_test.go b/notifications/formatters_test.go index 7121821..4bcc731 100644 --- a/notifications/formatters_test.go +++ b/notifications/formatters_test.go @@ -13,11 +13,11 @@ func TestGenericFormatter_Format(t *testing.T) { wantErr bool }{ { - name: "target_down_with_all_fields", + name: _eventTargetDown + "_with_all_fields", payload: WebhookPayload{ - Event: "target_down", + Event: _eventTargetDown, Target: "Test Service", - URL: "https://example.com", + URL: testURL, Timestamp: time.Date(2025, 10, 7, 12, 0, 0, 0, time.UTC), ResponseTimeMs: 150, StatusCode: 500, @@ -25,11 +25,11 @@ func TestGenericFormatter_Format(t *testing.T) { }, }, { - name: "target_up_with_minimal_fields", + name: _eventTargetUp + "_with_minimal_fields", payload: WebhookPayload{ - Event: "target_up", + Event: _eventTargetUp, Target: "Test Service", - URL: "https://example.com", + URL: testURL, Timestamp: time.Date(2025, 10, 7, 12, 0, 0, 0, time.UTC), ResponseTimeMs: 50, }, @@ -68,9 +68,9 @@ func TestSlackFormatter_Format(t *testing.T) { wantColor string }{ { - name: "target_down", + name: _eventTargetDown, payload: WebhookPayload{ - Event: "target_down", + Event: _eventTargetDown, Target: "API Service", URL: "https://api.example.com", Timestamp: time.Date(2025, 10, 7, 12, 0, 0, 0, time.UTC), @@ -81,9 +81,9 @@ func TestSlackFormatter_Format(t *testing.T) { wantColor: "danger", }, { - name: "target_up", + name: _eventTargetUp, payload: WebhookPayload{ - Event: "target_up", + Event: _eventTargetUp, Target: "API Service", URL: "https://api.example.com", Timestamp: time.Date(2025, 10, 7, 12, 0, 0, 0, time.UTC), @@ -132,9 +132,9 @@ func TestDiscordFormatter_Format(t *testing.T) { wantColor int }{ { - name: "target_down", + name: _eventTargetDown, payload: WebhookPayload{ - Event: "target_down", + Event: _eventTargetDown, Target: "Database", URL: "https://db.example.com", Timestamp: time.Date(2025, 10, 7, 12, 0, 0, 0, time.UTC), @@ -145,9 +145,9 @@ func TestDiscordFormatter_Format(t *testing.T) { wantColor: _discordColorRed, }, { - name: "target_up", + name: _eventTargetUp, payload: WebhookPayload{ - Event: "target_up", + Event: _eventTargetUp, Target: "Database", URL: "https://db.example.com", Timestamp: time.Date(2025, 10, 7, 12, 0, 0, 0, time.UTC), @@ -197,32 +197,32 @@ func TestSelectFormatter(t *testing.T) { { name: "slack_webhook_standard", url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX", - wantType: "*notifications.SlackFormatter", + wantType: slackFormatter, }, { name: "slack_webhook_uppercase", url: "HTTPS://HOOKS.SLACK.COM/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX", - wantType: "*notifications.SlackFormatter", + wantType: slackFormatter, }, { name: "discord_webhook_standard", url: "https://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz", - wantType: "*notifications.DiscordFormatter", + wantType: discordFormatter, }, { name: "discord_webhook_uppercase", url: "HTTPS://DISCORD.COM/API/WEBHOOKS/123456789012345678/abcdefghijklmnopqrstuvwxyz", - wantType: "*notifications.DiscordFormatter", + wantType: discordFormatter, }, { name: "generic_webhook_custom", - url: "https://example.com/webhook", - wantType: "*notifications.GenericFormatter", + url: testURL + "/webhook", + wantType: genericFormatter, }, { name: "generic_webhook_localhost", url: "http://localhost:8080/webhook", - wantType: "*notifications.GenericFormatter", + wantType: genericFormatter, }, } @@ -240,11 +240,11 @@ func TestSelectFormatter(t *testing.T) { func getFormatterType(f WebhookFormatter) string { switch f.(type) { case *SlackFormatter: - return "*notifications.SlackFormatter" + return slackFormatter case *DiscordFormatter: - return "*notifications.DiscordFormatter" + return discordFormatter case *GenericFormatter: - return "*notifications.GenericFormatter" + return genericFormatter default: return "unknown" } diff --git a/notifications/webhook.go b/notifications/webhook.go index f274df1..ab8dbd7 100644 --- a/notifications/webhook.go +++ b/notifications/webhook.go @@ -72,11 +72,11 @@ func HandleWebhookAlert(webhookURL string, headers []string, isUp bool, alertSen shouldSend := false if !isUp && !*alertSent { - event = "target_down" + event = _eventTargetDown shouldSend = true *alertSent = true } else if isUp && *alertSent { - event = "target_up" + event = _eventTargetUp shouldSend = true *alertSent = false } diff --git a/notifications/webhook_test.go b/notifications/webhook_test.go index dd6b615..4894512 100644 --- a/notifications/webhook_test.go +++ b/notifications/webhook_test.go @@ -21,9 +21,9 @@ func TestSendWebhook(t *testing.T) { { name: "successful webhook", payload: WebhookPayload{ - Event: "target_down", - Target: "Test Site", - URL: "https://example.com", + Event: _eventTargetDown, + Target: testSite, + URL: testURL, Timestamp: time.Now().UTC(), ResponseTimeMs: 1500, StatusCode: 500, @@ -36,9 +36,9 @@ func TestSendWebhook(t *testing.T) { { name: "webhook returns error status", payload: WebhookPayload{ - Event: "target_up", - Target: "Test Site", - URL: "https://example.com", + Event: _eventTargetUp, + Target: testSite, + URL: testURL, Timestamp: time.Now().UTC(), ResponseTimeMs: 200, StatusCode: 200, @@ -120,8 +120,8 @@ func TestHandleWebhookAlert(t *testing.T) { initialAlertSent: false, expectedAlertSent: true, expectWebhookCall: true, - targetName: "Test Site", - targetURL: "https://example.com", + targetName: testSite, + targetURL: testURL, }, { name: "target still down", @@ -129,8 +129,8 @@ func TestHandleWebhookAlert(t *testing.T) { initialAlertSent: true, expectedAlertSent: true, expectWebhookCall: false, - targetName: "Test Site", - targetURL: "https://example.com", + targetName: testSite, + targetURL: testURL, }, { name: "target comes up", @@ -138,8 +138,8 @@ func TestHandleWebhookAlert(t *testing.T) { initialAlertSent: true, expectedAlertSent: false, expectWebhookCall: true, - targetName: "Test Site", - targetURL: "https://example.com", + targetName: testSite, + targetURL: testURL, }, { name: "target still up", @@ -147,8 +147,8 @@ func TestHandleWebhookAlert(t *testing.T) { initialAlertSent: false, expectedAlertSent: false, expectWebhookCall: false, - targetName: "Test Site", - targetURL: "https://example.com", + targetName: testSite, + targetURL: testURL, }, { name: "empty target name uses URL", @@ -157,7 +157,7 @@ func TestHandleWebhookAlert(t *testing.T) { expectedAlertSent: true, expectWebhookCall: true, targetName: "", - targetURL: "https://example.com", + targetURL: testURL, }, } @@ -206,9 +206,9 @@ func TestHandleWebhookAlert(t *testing.T) { t.Errorf("Expected target %s, got %s", expectedTarget, receivedPayload.Target) } - expectedEvent := "target_down" + expectedEvent := _eventTargetDown if tc.isUp { - expectedEvent = "target_up" + expectedEvent = _eventTargetUp } if receivedPayload.Event != expectedEvent { t.Errorf("Expected event %s, got %s", expectedEvent, receivedPayload.Event) @@ -233,8 +233,8 @@ func TestHandleWebhookAlertEmptyURL(t *testing.T) { nil, false, &alertSent, - "Test Site", - "https://example.com", + testSite, + testURL, 1500*time.Millisecond, 500, "Server Error", diff --git a/stats/targets_test.go b/stats/targets_test.go index 322938f..c1a74a0 100644 --- a/stats/targets_test.go +++ b/stats/targets_test.go @@ -7,6 +7,10 @@ import ( "github.com/Owloops/updo/config" ) +const ( + apiServer = "api-server" +) + func TestTargetKey(t *testing.T) { tests := []struct { name string @@ -16,27 +20,27 @@ func TestTargetKey(t *testing.T) { }{ { name: "local target", - targetKey: NewLocalTargetKey("api-server", -1), - wantString: "api-server", - wantDisplay: "api-server", + targetKey: NewLocalTargetKey(apiServer, -1), + wantString: apiServer, + wantDisplay: apiServer, }, { name: "region target", - targetKey: NewRegionTargetKey("api-server", "us-east-1", -1), - wantString: "api-server@us-east-1", - wantDisplay: "api-server (us-east-1)", + targetKey: NewRegionTargetKey(apiServer, "us-east-1", -1), + wantString: apiServer + "@us-east-1", + wantDisplay: apiServer + " (us-east-1)", }, { name: "empty region treated as local", - targetKey: NewTargetKey("api-server", ""), - wantString: "api-server", - wantDisplay: "api-server", + targetKey: NewTargetKey(apiServer, ""), + wantString: apiServer, + wantDisplay: apiServer, }, { name: "local region treated as local", - targetKey: NewTargetKey("api-server", "local"), - wantString: "api-server", - wantDisplay: "api-server", + targetKey: NewTargetKey(apiServer, "local"), + wantString: apiServer, + wantDisplay: apiServer, }, } @@ -60,13 +64,13 @@ func TestParseTargetKey(t *testing.T) { }{ { name: "simple name", - keyStr: "api-server", - want: NewLocalTargetKey("api-server", -1), + keyStr: apiServer, + want: NewLocalTargetKey(apiServer, -1), }, { name: "name with region", - keyStr: "api-server@us-west-2", - want: NewRegionTargetKey("api-server", "us-west-2", -1), + keyStr: apiServer + "@us-west-2", + want: NewRegionTargetKey(apiServer, "us-west-2", -1), }, { name: "name with multiple @ symbols", @@ -101,39 +105,39 @@ func TestGetAllKeysForTarget(t *testing.T) { { name: "target with specific regions", target: config.Target{ - Name: "api-server", + Name: apiServer, Regions: []string{"us-east-1", "eu-west-1"}, }, globalRegions: []string{"us-west-2", "ap-south-1"}, index: 0, wantKeys: []TargetKey{ - NewRegionTargetKey("api-server#0", "us-east-1", 0), - NewRegionTargetKey("api-server#0", "eu-west-1", 0), + NewRegionTargetKey(apiServer+"#0", "us-east-1", 0), + NewRegionTargetKey(apiServer+"#0", "eu-west-1", 0), }, }, { name: "target with no regions uses global", target: config.Target{ - Name: "api-server", + Name: apiServer, Regions: []string{}, }, globalRegions: []string{"us-east-1", "us-west-2"}, index: 1, wantKeys: []TargetKey{ - NewRegionTargetKey("api-server#1", "us-east-1", 1), - NewRegionTargetKey("api-server#1", "us-west-2", 1), + NewRegionTargetKey(apiServer+"#1", "us-east-1", 1), + NewRegionTargetKey(apiServer+"#1", "us-west-2", 1), }, }, { name: "target with no regions and no global regions", target: config.Target{ - Name: "api-server", + Name: apiServer, Regions: []string{}, }, globalRegions: []string{}, index: 2, wantKeys: []TargetKey{ - NewLocalTargetKey("api-server#2", 2), + NewLocalTargetKey(apiServer+"#2", 2), }, }, }