From 2e88ee2269907337767487bf3eec573cdd80e528 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Sat, 6 Jun 2026 08:27:20 -0400 Subject: [PATCH] feat: clear stale plugin data and persist OAuth users to config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hbdclass: add per-plugin stale timers; clear history and alerts after 3× heartbeat interval with no PLG data received - udp: wire stale timer on every PLG message via _make_plugin_stale_callback - http: persist new OAuth users to config file on first login Co-Authored-By: Claude Sonnet 4.6 --- hbd/server/hbdclass.py | 32 ++++++++++++++++++++++++++++++++ hbd/server/http.py | 17 +++++++++++++++++ hbd/server/udp.py | 21 +++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/hbd/server/hbdclass.py b/hbd/server/hbdclass.py index d4d22b0..0fd3c94 100644 --- a/hbd/server/hbdclass.py +++ b/hbd/server/hbdclass.py @@ -297,6 +297,8 @@ class Host: self.plugin_retention = 100 # Keep last N samples per plugin # Alert state tracking: {metric_path: AlertState} self.alert_states = {} + # Stale-data timers: {plugin_name: asyncio.TimerHandle} + self.plugin_timers = {} # User access control self.owner: str | None = None # username of owner self.managers: list = [] # usernames with manager role @@ -483,6 +485,8 @@ class Host: self.managers = [] if not hasattr(self, "monitors"): self.monitors = [] + if not hasattr(self, "plugin_timers"): + self.plugin_timers = {} pass @@ -542,6 +546,34 @@ class Host: """ return self.plugin_data + def reset_plugin_timer(self, plugin_name, timeout_seconds, callback): + """Reset the stale-data timer for a plugin. + + If no new PLG data arrives within timeout_seconds, callback(host, plugin_name) + is called so the caller can clear history and alerts. + """ + import asyncio + existing = self.plugin_timers.get(plugin_name) + if existing and not existing.cancelled(): + existing.cancel() + + async def _fire(): + await callback(self, plugin_name) + + try: + loop = asyncio.get_event_loop() + self.plugin_timers[plugin_name] = loop.call_later( + timeout_seconds, lambda: asyncio.create_task(_fire()) + ) + except RuntimeError: + pass + + def cancel_plugin_timer(self, plugin_name): + """Cancel the stale timer for a plugin, if any.""" + handle = self.plugin_timers.pop(plugin_name, None) + if handle and not handle.cancelled(): + handle.cancel() + # ------------------------------------------------------------------ # User-role helpers # ------------------------------------------------------------------ diff --git a/hbd/server/http.py b/hbd/server/http.py index bb8017d..d834992 100644 --- a/hbd/server/http.py +++ b/hbd/server/http.py @@ -1182,6 +1182,23 @@ async def start( profile["full_name"], profile["avatar_url"], ) + # Persist new OAuth users to the config file so they survive restarts. + # Only write when the user isn't already in the config's users section. + if _config_path and not (config.get("users") or {}).get(user.username): + try: + disk_data = configio_mod.read_roundtrip(_config_path) + if not disk_data.get("users"): + disk_data["users"] = {} + disk_data["users"][user.username] = { + k: v for k, v in [ + ("full_name", user.full_name), + ("avatar", user.avatar), + ] if v + } + configio_mod.write_config(_config_path, disk_data) + logger.info("Persisted OAuth user %r to config", user.username) + except Exception as exc: + logger.warning("Failed to persist OAuth user %r to config: %s", user.username, exc) session_token = users_mod.create_session(user.username) eventlog("hbd", "INFO", f"Login: {user.username} via {provider.type}") resp = web.HTTPFound("/") diff --git a/hbd/server/udp.py b/hbd/server/udp.py index 6470bb2..20cf4bf 100644 --- a/hbd/server/udp.py +++ b/hbd/server/udp.py @@ -232,6 +232,23 @@ def _make_timer_callbacks(uname, host, ctx): return on_overdue, on_unknown +def _make_plugin_stale_callback(uname, ctx): + """Return an async callback that clears stale plugin data and its alerts.""" + msg_to_websockets = ctx.get("msg_to_websockets") + + async def on_plugin_stale(host, plugin_name): + host.plugin_data.pop(plugin_name, None) + stale_keys = [k for k in host.alert_states if k.startswith(f"{plugin_name}.")] + for k in stale_keys: + del host.alert_states[k] + eventlog(uname, "INFO", f"plugin data stale: {plugin_name}") + if msg_to_websockets: + msg_to_websockets("plugin_stale", {"host": uname, "plugin": plugin_name}) + msg_to_websockets("host", host.stateinfo()) + + return on_plugin_stale + + def restore_connection_timers(hbdclass, ctx): """Restore overdue timers for all loaded connections after a pickle restore. @@ -372,6 +389,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict): if k not in ("ID", "plugin", "id", "name")} # Store plugin data with timestamp host.add_plugin_data(plugin_name, plugin_data, timestamp=now) + # Reset stale timer — 3× the heartbeat interval (min 60 s) + stale_timeout = max(host.interval * 3, 60) + host.reset_plugin_timer(plugin_name, stale_timeout, + _make_plugin_stale_callback(uname, ctx)) # If os_info reports an owner and none is configured server-side, apply it if plugin_name == "os_info":