diff --git a/.gitea/workflows/release-binaries.yml b/.gitea/workflows/release-binaries.yml index 415e21c..85dd4ba 100644 --- a/.gitea/workflows/release-binaries.yml +++ b/.gitea/workflows/release-binaries.yml @@ -52,3 +52,38 @@ jobs: GORELEASER_FORCE_TOKEN: gitea GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITEA_API_URL: http://gitea:3000/api/v1 + + # GoReleaser publishes to exactly one SCM (Gitea). The push mirror + # carries git refs but not release artifacts, so relay the release to + # the GitHub mirror — README install links point there. + - name: Mirror release to GitHub + env: + GH_TOKEN: ${{ secrets.GH_MIRROR_TOKEN }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "GH_MIRROR_TOKEN not set — skipping GitHub release relay" + exit 0 + fi + apk add --no-cache github-cli + TAG="${{ github.ref_name }}" + + # Nudge the push mirror, then wait for the tag to land on GitHub. + curl -sf -X POST \ + -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ + "http://gitea:3000/api/v1/repos/lerkolabs/uptop/push_mirrors-sync" || true + for i in $(seq 1 30); do + if gh api "repos/lerkolabs/uptop/git/ref/tags/${TAG}" >/dev/null 2>&1; then + break + fi + sleep 10 + done + + PRERELEASE="" + case "$TAG" in *-*) PRERELEASE="--prerelease";; esac + gh release create "$TAG" \ + --repo lerkolabs/uptop \ + --verify-tag \ + --title "$TAG" \ + --notes-file /tmp/release-notes.md \ + $PRERELEASE \ + dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt diff --git a/internal/importer/kuma_test.go b/internal/importer/kuma_test.go new file mode 100644 index 0000000..2ba494f --- /dev/null +++ b/internal/importer/kuma_test.go @@ -0,0 +1,210 @@ +package importer + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTemp(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "backup.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadKumaFileMissingFile(t *testing.T) { + _, err := LoadKumaFile(filepath.Join(t.TempDir(), "nope.json")) + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestLoadKumaFileMalformedInput(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"empty file", ""}, + {"truncated JSON", `{"version": "1.23", "monitorList": [`}, + {"not JSON", "definitely not json"}, + {"wrong root type", `[1, 2, 3]`}, + {"monitorList wrong type", `{"monitorList": {"a": 1}}`}, + {"monitor field wrong type", `{"monitorList": [{"id": "not-an-int"}]}`}, + {"notificationList wrong type", `{"notificationList": "oops"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := LoadKumaFile(writeTemp(t, tc.body)) + if err == nil { + t.Fatalf("expected parse error for %s", tc.name) + } + if !strings.Contains(err.Error(), "parse JSON") { + t.Fatalf("expected wrapped parse error, got: %v", err) + } + }) + } +} + +func TestLoadKumaFileNullLists(t *testing.T) { + kb, err := LoadKumaFile(writeTemp(t, `{"version": "1.23", "monitorList": null, "notificationList": null}`)) + if err != nil { + t.Fatal(err) + } + backup := ConvertKuma(kb) + if len(backup.Sites) != 0 || len(backup.Alerts) != 0 { + t.Fatalf("expected empty backup, got %d sites %d alerts", len(backup.Sites), len(backup.Alerts)) + } +} + +func TestConvertKumaSkipsMalformedNotificationConfig(t *testing.T) { + kb := &KumaBackup{ + NotificationList: []KumaNotifEntry{ + {ID: 1, Name: "broken", Config: "{not json"}, + {ID: 2, Name: "good", Config: `{"type": "discord", "ntfyserverurl": "https://example.com/hook"}`}, + }, + MonitorList: []KumaMonitor{ + {ID: 10, Name: "site", Type: "http", URL: "https://example.com", NotificationIDs: map[string]bool{"1": true}}, + }, + } + backup := ConvertKuma(kb) + if len(backup.Alerts) != 1 { + t.Fatalf("expected broken notification skipped, got %d alerts", len(backup.Alerts)) + } + if backup.Alerts[0].Type != "discord" { + t.Fatalf("expected discord alert, got %q", backup.Alerts[0].Type) + } + if backup.Sites[0].AlertID != 0 { + t.Fatalf("site referencing skipped notification should keep AlertID 0, got %d", backup.Sites[0].AlertID) + } +} + +func TestConvertKumaNtfyNotification(t *testing.T) { + kb := &KumaBackup{ + NotificationList: []KumaNotifEntry{ + {ID: 3, Name: "ntfy", Config: `{ + "type": "ntfy", + "ntfyserverurl": "https://ntfy.example.com/", + "ntfytopic": "uptime", + "ntfyPriority": 4, + "ntfyAuthenticationMethod": "usernamePassword", + "ntfyusername": "u", + "ntfypassword": "p" + }`}, + }, + } + backup := ConvertKuma(kb) + if len(backup.Alerts) != 1 { + t.Fatalf("expected 1 alert, got %d", len(backup.Alerts)) + } + a := backup.Alerts[0] + if a.Type != "ntfy" { + t.Fatalf("expected ntfy, got %q", a.Type) + } + if a.Settings["url"] != "https://ntfy.example.com" { + t.Fatalf("expected trailing slash trimmed, got %q", a.Settings["url"]) + } + if a.Settings["topic"] != "uptime" || a.Settings["priority"] != "4" { + t.Fatalf("unexpected settings: %v", a.Settings) + } + if a.Settings["username"] != "u" || a.Settings["password"] != "p" { + t.Fatalf("expected credentials mapped, got %v", a.Settings) + } +} + +func TestConvertKumaUnknownNotificationFallsBackToWebhook(t *testing.T) { + kb := &KumaBackup{ + NotificationList: []KumaNotifEntry{ + {ID: 4, Name: "matrix", Config: `{"type": "matrix", "ntfyserverurl": "https://example.com/hook"}`}, + }, + } + backup := ConvertKuma(kb) + if len(backup.Alerts) != 1 || backup.Alerts[0].Type != "webhook" { + t.Fatalf("expected webhook fallback, got %+v", backup.Alerts) + } +} + +func TestConvertKumaHTTPMonitor(t *testing.T) { + kb := &KumaBackup{ + NotificationList: []KumaNotifEntry{ + {ID: 1, Name: "hook", Config: `{"type": "slack", "ntfyserverurl": "https://example.com/hook"}`}, + }, + MonitorList: []KumaMonitor{{ + ID: 7, + Name: "web", + Type: "http", + URL: "https://example.com", + Interval: 60, + Timeout: 30, + MaxRetries: 2, + Method: "GET", + AcceptedCodes: []string{"200", "301"}, + IgnoreTLS: true, + ExpiryNotif: true, + Active: false, + NotificationIDs: map[string]bool{"1": true}, + }}, + } + backup := ConvertKuma(kb) + if len(backup.Sites) != 1 { + t.Fatalf("expected 1 site, got %d", len(backup.Sites)) + } + s := backup.Sites[0] + if s.URL != "https://example.com" || !s.CheckSSL || !s.IgnoreTLS { + t.Fatalf("http fields not mapped: %+v", s) + } + if !s.Paused { + t.Fatal("inactive monitor should import paused") + } + if s.AcceptedCodes != "200,301" { + t.Fatalf("expected joined accepted codes, got %q", s.AcceptedCodes) + } + if s.AlertID != 1 { + t.Fatalf("expected alert mapped, got %d", s.AlertID) + } +} + +func TestConvertKumaPushMonitorGetsToken(t *testing.T) { + kb := &KumaBackup{ + MonitorList: []KumaMonitor{{ID: 1, Name: "push", Type: "push", Active: true}}, + } + backup := ConvertKuma(kb) + token := backup.Sites[0].Token + if len(token) != 32 { + t.Fatalf("expected 32-char hex token, got %q", token) + } +} + +func TestConvertKumaNonNumericNotificationID(t *testing.T) { + kb := &KumaBackup{ + MonitorList: []KumaMonitor{{ + ID: 1, + Name: "site", + Type: "http", + NotificationIDs: map[string]bool{"abc": true}, + }}, + } + backup := ConvertKuma(kb) + if backup.Sites[0].AlertID != 0 { + t.Fatalf("non-numeric notification ID should not map, got %d", backup.Sites[0].AlertID) + } +} + +func TestConvertKumaGroupAndChildren(t *testing.T) { + kb := &KumaBackup{ + MonitorList: []KumaMonitor{ + {ID: 1, Name: "grp", Type: "group", Active: true}, + {ID: 2, Name: "ping", Type: "ping", Hostname: "10.0.0.1", Parent: 1, Active: true}, + }, + } + backup := ConvertKuma(kb) + if backup.Sites[0].Type != "group" { + t.Fatalf("expected group type, got %q", backup.Sites[0].Type) + } + if backup.Sites[1].ParentID != 1 || backup.Sites[1].Hostname != "10.0.0.1" { + t.Fatalf("child not mapped: %+v", backup.Sites[1]) + } +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 63a12bd..779667c 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -115,10 +115,14 @@ func newEngine(s store.Store, allowPrivateTargets bool) *Engine { } } +// SetInsecureSkipVerify must be called before Start: the field is read by +// checker goroutines without synchronization. func (e *Engine) SetInsecureSkipVerify(skip bool) { e.insecureSkipVerify = skip } +// SetMaintRetention must be called before Start: the field is read by the +// maintenance prune goroutine without synchronization. func (e *Engine) SetMaintRetention(d time.Duration) { e.maintRetention = d } @@ -1043,6 +1047,8 @@ func (e *Engine) EnqueueProbeCheck(siteID int, nodeID string, latencyNs int64, i e.enqueueWrite(writeProbeCheck{siteID: siteID, nodeID: nodeID, latencyNs: latencyNs, isUp: isUp}) } +// SetAggStrategy must be called before Start: the field is read by the probe +// aggregation path without synchronization. func (e *Engine) SetAggStrategy(strategy AggregationStrategy) { e.aggStrategy = strategy } diff --git a/internal/tui/data.go b/internal/tui/data.go index 23e48d5..a04ff25 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -104,7 +104,7 @@ func (m *Model) refreshLive() { ordered = filterSites(ordered, m.filterText) } m.sites = ordered - m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n")) + m.refreshLogContent() if m.currentTab == 0 && m.selectedID != 0 { for i, s := range m.sites { diff --git a/internal/tui/tab_logs.go b/internal/tui/tab_logs.go index 480e8f3..3769a3e 100644 --- a/internal/tui/tab_logs.go +++ b/internal/tui/tab_logs.go @@ -82,18 +82,15 @@ func (m Model) renderLogLine(line string) string { return fmt.Sprintf(" %s %s", tag, msg) } -func (m Model) viewLogsTab() string { - content := m.logViewport.View() - if strings.TrimSpace(content) == "" || content == "Waiting for logs..." { - return m.emptyState("No log entries yet.", "Logs appear as monitors run checks") - } - - lines := strings.Split(content, "\n") +// refreshLogContent rebuilds the log viewport from the full engine log list, +// filtering before windowing so the entry count and "(n hidden)" reflect all +// logs, not just the visible viewport slice. +func (m *Model) refreshLogContent() { var rendered []string total := 0 shown := 0 - for _, line := range lines { + for _, line := range m.engine.GetLogs() { if strings.TrimSpace(line) == "" { continue } @@ -106,18 +103,27 @@ func (m Model) viewLogsTab() string { rendered = append(rendered, m.renderLogLine(line)) } + m.logTotal = total + m.logShown = shown + m.logViewport.SetContent(strings.Join(rendered, "\n")) +} + +func (m Model) viewLogsTab() string { + if m.logTotal == 0 { + return m.emptyState("No log entries yet.", "Logs appear as monitors run checks") + } + filterLabel := "All" if m.logFilterImportant { filterLabel = "Important" } header := m.st.subtleStyle.Render(fmt.Sprintf( - " %d entries Filter: %s", shown, filterLabel)) + " %d entries Filter: %s", m.logShown, filterLabel)) - if m.logFilterImportant && shown < total { - header += m.st.subtleStyle.Render(fmt.Sprintf(" (%d hidden)", total-shown)) + if m.logFilterImportant && m.logShown < m.logTotal { + header += m.st.subtleStyle.Render(fmt.Sprintf(" (%d hidden)", m.logTotal-m.logShown)) } - m.logViewport.SetContent(strings.Join(rendered, "\n")) return "\n" + header + "\n\n" + m.logViewport.View() } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 36fa3b4..0b9647f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -121,6 +121,8 @@ type Model struct { logViewport viewport.Model logFilterImportant bool + logTotal int + logShown int historyViewport viewport.Model historyChanges []models.StateChange diff --git a/internal/tui/update.go b/internal/tui/update.go index 194d396..e476617 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -527,6 +527,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "f": if m.state == stateLogs { m.logFilterImportant = !m.logFilterImportant + m.refreshLogContent() return m, nil } case "tab":