feat(alerts): add alert for failed systemd services - #2173
feat(alerts): add alert for failed systemd services#2173martinstenrose wants to merge 2 commits into
Conversation
09a026e to
64a0dd1
Compare
|
+1 for per-service failure selection please 🙂 |
Thanks for the feedback! I went with system-wide mainly because it was much simpler to implement and covers what I actually needed. I think it’s also the more common case – you generally don’t want any service left in a failed state, and
Given limited time, I opted for the simpler system-wide approach for now – per-service selection could be added as a follow-up if there’s demand. |
|
Yep, just wanted to indicate here since in the fr issue you said to 🙂 |
Adds a user-configurable "Failed Services" alert that notifies when any tracked systemd service enters the failed state, and again when all services recover. No agent changes are needed. Closes henrygd#1813 systemd_services rows are written via a raw batched SQL upsert, so PocketBase record hooks never fire for that collection. The check therefore runs from HandleSystemAlerts, which is invoked on every update cycle. State is read from the systemd_services snapshot rather than the update payload. Realtime dashboard subscriptions fetch with a shorter cache time, which the agent answers without systemd data, overwriting the cached payload roughly once per second while a system is being viewed. The snapshot table is written only by the full update cycle. A system with no rows is treated as "no systemd data" rather than "nothing failed", so it never produces a spurious recovery. Only rows from the most recent update are considered. The table is upserted and never pruned on change, so a unit that ceases to exist rather than merely recovering, such as a transient systemd-run unit after reset-failed, stops being reported but keeps its last known state until the retention sweep. Every row written in one cycle shares a single updated timestamp, so the newest timestamp identifies what the agent last reported. The services table applies the same rule, rendering only rows within seventy seconds of the newest, so scoping here keeps the alert and the table in agreement about what is currently failing. The alert fires on first observation rather than offering a "for N minutes" delay: the agent only refreshes systemd state every 10 minutes, so a shorter delay would re-check identical data and could never cancel. The duration slider is hidden via a new noDuration flag rather than presenting a control that cannot affect the outcome. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Martin Stenröse <martin@stenrose.se>
systemd_services is upserted every update cycle but nothing removes rows for services that stop being reported, so a unit deleted from the host keeps its last known state until the retention sweep, which is hourly and only considers rows older than twenty minutes. Until then the orphaned row surfaces inconsistently. The all systems dashboard counts services from the agent and never sees it. The per system services table renders rows within seventy seconds of the newest, so it shows the row until its next poll, up to ten minutes later. Anything reading the table directly sees it for over an hour. Delete rows older than the batch timestamp after each upsert. Rows written in one cycle share a single updated value, so anything older no longer exists on the host. createSystemdStatsRecords is only called with a non-empty payload, so an agent reporting no services cannot clear the table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Martin Stenröse <martin@stenrose.se>
64a0dd1 to
d99e24e
Compare

📃 Description
Adds a user-configurable Failed Services alert that notifies when any tracked systemd service enters the
failedstate, and again when all services recover. Resolves #1813.The agent already reports per-service state, so no agent changes are needed – existing agents work unmodified.
Notes that may be worth a look during review:
systemd_servicesrows are written via a raw batched SQL upsert, so PocketBase record hooks never fire for that collection. The check runs fromHandleSystemAlertsinstead, which is invoked on every update cycle. It's excluded from the numeric threshold loop, asStatusalready is.systemd_servicessnapshot, not the update payload. Realtime dashboard subscriptions fetch from the agent with a shorter cache time, which the agent answers without systemd data – so an open dashboard overwrites the cached payload roughly once per second. Reading the payload makes the alert silently miss failures whenever someone is looking at the system page.systemd-rununit afterreset-failed) stops being reported but keeps its last known state until the retention sweep. Rows written in one cycle share a singleupdatedtimestamp, so the newest identifies what the agent last reported. The services table applies the same rule – it renders only rows within 70s of the newest – so this keeps the alert and the table in agreement.failedunit is also a sticky state rather than a flapping one, so there is less to debounce than forStatus. The duration slider is hidden via a newnoDurationflag rather than presenting a control that cannot affect the outcome.minfield and slider code are untouched.Granularity is system-wide ("any tracked service failed"), consistent with the existing Disk/Temperature/GPU alerts, with failed service names listed in the notification body.
SERVICE_PATTERNSon the agent already scopes what is tracked.The second commit,
fix(hub): remove systemd services that are no longer reported, fixes a pre-existing data-correctness bug that this feature surfaced - see the Fixed section. It's independent of the alert; happy to split it into its own PR if you'd prefer, the alert is correct either way.Locale catalogs are left untouched, since translations are handled via Crowdin – the two new strings are
tmacros inlib/alerts.tsand will be picked up by the next extraction.Verified against two hosts: single and multiple failures, recovery, and correct alerting while a stale orphaned row was present. The row removal in the second commit is covered by unit tests rather than live testing.
Note:
go test ./internal/alerts/currently fails on a pre-existing teardown race unrelated to this change – aStartUpdatergoroutine sleeps 11s (system.go:82) then callssetDown()against a hub whose test DB is already closed. The existing suite finishes at ~13.1s, right as those timers fire, so appending any test exposes it. Reproducible on a clean checkout withgo test -tags testing ./internal/alerts/ -count=2. The new tests pass in isolation (-runSystemd). Happy to open a separate issue for it.Note: Written with the help of an AI agent (Claude Code). The design decisions, review, and testing against my own hosts are mine; the commits carry Co-Authored-By trailers.
📖 Documentation
henrygd/beszel-docs#64
🪵 Changelog
➕ Added
SystemdFailed) alert type – triggers when any tracked systemd service enters the failed state, listing the failed service names in the notificationSystemdFailedto thealerts.nameselect fieldnoDurationflag onAlertInfo, so alert types that fire on first observation don't render a duration sliderinternal/alerts/alerts_systemd_test.go(12 cases: immediate fire, full fail/recover cycle, re-notification suppression, repeated-failure idempotence, orphaned rows, missing-data handling, multi-user, stale-state resolution)✏️ Changed
HandleSystemAlertsnow dispatches systemd alerts and excludesSystemdFailedfrom the numeric threshold loopmindefaults to 0 for alert types that fire immediately, rather than storing an unused 10 minute delay🔧 Fixed
systemd_serviceskept the last known state until the retention sweep. The all-systems dashboard counts services from the agent and dropped it immediately, while the per-system services table reads the stored rows and kept showing it – e.g. a dashboard reading44 (failed: 0)against a services tab showingTotal: 45 / Failed: 1for the same host. Rows are now deleted at write time once the agent stops reporting them.📷 Screenshots