From 3301dbfe34dde5ff8e17fce802931cc52c92ca76 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Mon, 4 May 2026 08:03:46 -0400 Subject: [PATCH] feat: owner Update/Delete buttons on Host Overview; purge stale alerts on reload Host Overview (plugins.html): show Update and Delete buttons in the host-right zone when the logged-in user is the host owner (or admin / unauthenticated mode). Buttons link to /u?h= and /d?h= with stopPropagation so they don't toggle the accordion; Delete prompts for confirmation first. ThresholdChecker.purge_stale_alerts(): removes alert states whose metric_path has no matching threshold in the current config. Called after startup pickle restore and after every SIGHUP config reload so alerts orphaned by upgrades or config changes do not persist indefinitely. Co-Authored-By: Claude Sonnet 4.6 --- hbd/server/http.py | 1 + hbd/server/main.py | 7 ++++++- hbd/server/templates/plugins.html | 29 +++++++++++++++++++++++++++++ hbd/server/threshold.py | 20 ++++++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/hbd/server/http.py b/hbd/server/http.py index 4ce5070..c40e3fe 100644 --- a/hbd/server/http.py +++ b/hbd/server/http.py @@ -537,6 +537,7 @@ async def start( hosts_with_plugins.append({ "name": hostname, "plugins": list(host.plugin_data.keys()), + "is_owner": _can_own_host(current_user, host), }) tmpl = env.get_template("plugins.html") diff --git a/hbd/server/main.py b/hbd/server/main.py index 2c62688..95701bd 100644 --- a/hbd/server/main.py +++ b/hbd/server/main.py @@ -101,9 +101,10 @@ async def reload_configuration(config_obj, config_path, components): access = config_mod.get_host_access(new_config, hostname) host.apply_access(access["owner"], access["managers"], access["monitors"]) - # Reload threshold checker + # Reload threshold checker and prune alerts orphaned by the new config if 'threshold_checker' in components: components['threshold_checker'].reload(new_config) + components['threshold_checker'].purge_stale_alerts(hbdclass) # Note: Changes to the following require restart: # - hb_port, hbd_port, ws_port (already bound) @@ -241,6 +242,10 @@ async def _run_async(config, config_path=None): ) udp.restore_connection_timers(hbdclass, restore_ctx) + # Drop alert states that no longer have a matching threshold (stale after + # upgrade or config change between runs). + threshold_checker.purge_stale_alerts(hbdclass) + # HTTP server (asyncio-based via aiohttp) try: http_task = asyncio.create_task( diff --git a/hbd/server/templates/plugins.html b/hbd/server/templates/plugins.html index 41f1ff2..a74de89 100644 --- a/hbd/server/templates/plugins.html +++ b/hbd/server/templates/plugins.html @@ -131,6 +131,27 @@ text-overflow: ellipsis; } + .host-action-btn { + font-size: 0.75em; + font-weight: bold; + padding: 3px 10px; + border-radius: 4px; + border: none; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + } + .host-action-btn.update-btn { + background: #e3f2fd; + color: #1565c0; + } + .host-action-btn.update-btn:hover { background: #bbdefb; } + .host-action-btn.delete-btn { + background: #ffebee; + color: #c62828; + } + .host-action-btn.delete-btn:hover { background: #ffcdd2; } + /* ── Host body ──────────────────────────────────────────────── */ .host-body { @@ -379,6 +400,14 @@ {% endif %} + {% if host.is_owner %} + Update + Delete + {% endif %} diff --git a/hbd/server/threshold.py b/hbd/server/threshold.py index fa4f5a1..9d86a00 100644 --- a/hbd/server/threshold.py +++ b/hbd/server/threshold.py @@ -1276,6 +1276,26 @@ class ThresholdChecker: alert_state.last_notification = now alert_state.notification_count += 1 + def purge_stale_alerts(self, hbdclass) -> None: + """Remove alert states that have no matching threshold configuration. + + Called after startup (pickle restore) and after each config reload so + that alerts orphaned by configuration changes do not linger forever. + Alerts whose metric_path is not present in the current threshold config + for that host are silently dropped. + """ + for hostname, host in hbdclass.Host.hosts.items(): + if not host.alert_states: + continue + configured = self.get_thresholds_for_host(hostname) + stale = [mp for mp in host.alert_states if mp not in configured] + for mp in stale: + logger.info( + "Purging stale alert state for %s / %s (no threshold configured)", + hostname, mp, + ) + del host.alert_states[mp] + def get_active_alerts(self, alert_states: Dict[str, AlertState]) -> list: """ Get all currently active (non-OK) alerts.