fix/106-notify-shutdown-drain
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
970ea9fae8 |
notify: drain in-flight deliveries at shutdown (closes #106)
All checks were successful
check / check (push) Successful in 33s
notify.New accepted an fx.Lifecycle and never used it, so the three dispatch goroutines were untracked. context.WithoutCancel kept a delivery alive past its caller's cancellation but made nothing wait for it: the process could exit while a delivery was still in its retry backoff (up to five attempts, 60s max delay), silently losing exactly the alert most worth keeping. Deliveries are now tracked in a sync.WaitGroup whose counter is incremented on the dispatching goroutine before the worker starts, and notify.New registers an OnStop hook that drains them. The drain is bounded by the context fx passes to OnStop; when it expires with work outstanding, the count is logged at warn level and parked retry backoffs are released via an abandon channel so they stop retrying rather than outliving the drain. Deliveries submitted after the drain has begun are refused and logged, so a stream of new notifications cannot extend shutdown indefinitely. The three near-identical dispatchers now share one tracked dispatch helper. Tests use httptest servers and the existing retry knobs (SetRetryConfig/SetSleepFunc) so nothing waits on a real backoff. README's shutdown claim is reworded to match the bounded semantics. |
||
| 23f115053b |
feat: add retry with exponential backoff for notification delivery (#87)
All checks were successful
check / check (push) Successful in 37s
## Summary Notifications were fire-and-forget: if Slack, Mattermost, or ntfy was temporarily down, changes were silently lost. This adds automatic retry with exponential backoff and jitter to all notification endpoints. ## Changes ### New file: `internal/notify/retry.go` - `RetryConfig` struct with configurable max retries, base delay, max delay - `backoff()` computes delay as `BaseDelay * 2^attempt`, capped at `MaxDelay`, with ±25% jitter - `deliverWithRetry()` wraps any send function with the retry loop - Defaults: 3 retries (4 total attempts), 1s base delay, 10s max delay - Context-aware: respects cancellation during retry sleep - Injectable `sleepFn` for test determinism ### Modified: `internal/notify/notify.go` - Added `retryConfig` and `sleepFn` fields to `Service` - Updated `dispatchNtfy`, `dispatchSlack`, `dispatchMattermost` to wrap sends in `deliverWithRetry` - Structured logging: warns on each retry, logs error only after all retries exhausted, logs info on success after retry ### Modified: `internal/notify/export_test.go` - Added test helpers: `SetRetryConfig`, `SetSleepFunc`, `DeliverWithRetry`, `BackoffDuration` ### New file: `internal/notify/retry_test.go` - Backoff calculation tests (exponential increase, max cap with jitter) - `deliverWithRetry` unit tests: first-attempt success, transient failure recovery, exhausted retries, context cancellation - Integration tests via `SendNotification`: transient failure retries, all-endpoints retry independently, permanent failure exhausts retries ## Verification - `make fmt` ✅ - `make check` (format + lint + tests + build) ✅ - `docker build .` ✅ - All existing tests continue to pass unchanged - No DNS client mocking — notification tests use `httptest` servers closes #62 Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Reviewed-on: #87 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org> |
|||
| 1076543c23 |
feat: add unauthenticated web dashboard showing monitoring state and recent alerts (#83)
All checks were successful
check / check (push) Successful in 4s
## Summary Adds a read-only web dashboard at `GET /` that shows the current monitoring state and recent alerts. Unauthenticated, single-page, no navigation. ## What it shows - **Summary bar**: counts of monitored domains, hostnames, ports, certificates - **Domains**: nameservers with last-checked age - **Hostnames**: per-nameserver DNS records, status badges, relative age - **Ports**: open/closed state with associated hostnames and age - **TLS Certificates**: CN, issuer, expiry (color-coded by urgency), status, age - **Recent Alerts**: last 100 notifications in reverse chronological order with priority badges Every data point displays its age (e.g. "5m ago") so freshness is visible at a glance. Auto-refreshes every 30 seconds. ## What it does NOT show No secrets: webhook URLs, ntfy topics, Slack/Mattermost endpoints, API tokens, and configuration details are never exposed. ## Design All assets (CSS) are embedded in the binary and served from `/s/`. Zero external HTTP requests at runtime — no CDN dependencies or third-party resources. Dark, technical aesthetic with saturated teals and blues on dark slate. Single page — everything on one screen. ## Implementation - `internal/notify/history.go` — thread-safe ring buffer (`AlertHistory`) storing last 100 alerts - `internal/notify/notify.go` — records each alert in history before dispatch; refactored `SendNotification` into smaller `dispatch*` helpers to satisfy funlen - `internal/handlers/dashboard.go` — `HandleDashboard()` handler with embedded HTML template, helper functions (`relTime`, `formatRecords`, `expiryDays`, `joinStrings`) - `internal/handlers/templates/dashboard.html` — Tailwind-styled single-page dashboard - `internal/handlers/handlers.go` — added `State` and `Notify` dependencies via fx - `internal/server/routes.go` — registered `GET /` route - `static/` — embedded CSS assets served via `/s/` prefix - `README.md` — documented the dashboard and new endpoint ## Tests - `internal/notify/history_test.go` — empty, add+recent ordering, overflow beyond capacity - `internal/handlers/dashboard_test.go` — `relTime`, `expiryDays`, `formatRecords` - All existing tests pass unchanged - `docker build .` passes closes [#82](#82) <!-- session: rework-pr-83 --> Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Reviewed-on: #83 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org> |
|||
| 1843d09eb3 |
test(notify): add comprehensive tests for notification delivery (#79)
All checks were successful
check / check (push) Successful in 50s
## Summary Add comprehensive tests for the `internal/notify` package, improving coverage from 11.1% to 80.0%. Closes [issue #71](#71). ## What was added ### `delivery_test.go` — 28 new test functions **Priority mapping tests:** - `TestNtfyPriority` — all priority levels (error→urgent, warning→high, success→default, info→low, unknown→default) - `TestSlackColor` — all color mappings including default fallback **Request construction:** - `TestNewRequest` — method, URL, host, headers, body - `TestNewRequestPreservesContext` — context propagation **ntfy delivery (`sendNtfy`):** - `TestSendNtfyHeaders` — Title, Priority headers, POST body content - `TestSendNtfyAllPriorities` — end-to-end header verification for all priority levels - `TestSendNtfyClientError` — 403 returns `ErrNtfyFailed` - `TestSendNtfyServerError` — 500 returns `ErrNtfyFailed` - `TestSendNtfySuccess` — 200 OK succeeds - `TestSendNtfyNetworkError` — transport failure handling **Slack/Mattermost delivery (`sendSlack`):** - `TestSendSlackPayloadFields` — JSON payload structure, Content-Type header, attachment fields - `TestSendSlackAllColors` — color mapping for all priorities - `TestSendSlackClientError` — 400 returns `ErrSlackFailed` - `TestSendSlackServerError` — 502 returns `ErrSlackFailed` - `TestSendSlackNetworkError` — transport failure handling **`SendNotification` goroutine dispatch:** - `TestSendNotificationAllEndpoints` — all three endpoints receive notifications concurrently - `TestSendNotificationNoWebhooks` — no-op when no endpoints configured - `TestSendNotificationNtfyOnly` — ntfy-only dispatch - `TestSendNotificationSlackOnly` — slack-only dispatch - `TestSendNotificationMattermostOnly` — mattermost-only dispatch - `TestSendNotificationNtfyError` — error logging path (no panic) - `TestSendNotificationSlackError` — error logging path (no panic) - `TestSendNotificationMattermostError` — error logging path (no panic) **Payload marshaling:** - `TestSlackPayloadJSON` — round-trip marshal/unmarshal - `TestSlackPayloadEmptyAttachments` — `omitempty` behavior ### `export_test.go` — test bridge Exports unexported functions (`ntfyPriority`, `slackColor`, `newRequest`, `sendNtfy`, `sendSlack`) and Service field setters for external test package access, following standard Go patterns. ## Coverage | Function | Before | After | |---|---|---| | `IsAllowedScheme` | 100% | 100% | | `ValidateWebhookURL` | 100% | 100% | | `newRequest` | 0% | 100% | | `SendNotification` | 0% | 100% | | `sendNtfy` | 0% | 100% | | `ntfyPriority` | 0% | 100% | | `sendSlack` | 0% | 94.1% | | `slackColor` | 0% | 100% | | **Total** | **11.1%** | **80.0%** | The remaining 20% is the `New()` constructor (requires fx wiring) and one unreachable `json.Marshal` error path in `sendSlack`. ## Testing approach - `httptest.Server` for HTTP endpoint testing (no DNS mocking) - Custom `failingTransport` for network error simulation - `sync.Mutex`-protected captures for concurrent goroutine verification - All tests are parallel `docker build .` passes ✅ <!-- session: agent:sdlc-manager:subagent:6158e09a-aba4-4778-89ca-c12b22014ccd --> Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: Jeffrey Paul <sneak@noreply.example.org> Reviewed-on: #79 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org> |