feat: clear stale plugin data and persist OAuth users to config

- 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 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-06-06 08:27:20 -04:00
parent d9563392c3
commit 2e88ee2269
3 changed files with 70 additions and 0 deletions
+32
View File
@@ -297,6 +297,8 @@ class Host:
self.plugin_retention = 100 # Keep last N samples per plugin self.plugin_retention = 100 # Keep last N samples per plugin
# Alert state tracking: {metric_path: AlertState} # Alert state tracking: {metric_path: AlertState}
self.alert_states = {} self.alert_states = {}
# Stale-data timers: {plugin_name: asyncio.TimerHandle}
self.plugin_timers = {}
# User access control # User access control
self.owner: str | None = None # username of owner self.owner: str | None = None # username of owner
self.managers: list = [] # usernames with manager role self.managers: list = [] # usernames with manager role
@@ -483,6 +485,8 @@ class Host:
self.managers = [] self.managers = []
if not hasattr(self, "monitors"): if not hasattr(self, "monitors"):
self.monitors = [] self.monitors = []
if not hasattr(self, "plugin_timers"):
self.plugin_timers = {}
pass pass
@@ -542,6 +546,34 @@ class Host:
""" """
return self.plugin_data 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 # User-role helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+17
View File
@@ -1182,6 +1182,23 @@ async def start(
profile["full_name"], profile["full_name"],
profile["avatar_url"], 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) session_token = users_mod.create_session(user.username)
eventlog("hbd", "INFO", f"Login: {user.username} via {provider.type}") eventlog("hbd", "INFO", f"Login: {user.username} via {provider.type}")
resp = web.HTTPFound("/") resp = web.HTTPFound("/")
+21
View File
@@ -232,6 +232,23 @@ def _make_timer_callbacks(uname, host, ctx):
return on_overdue, on_unknown 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): def restore_connection_timers(hbdclass, ctx):
"""Restore overdue timers for all loaded connections after a pickle restore. """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")} if k not in ("ID", "plugin", "id", "name")}
# Store plugin data with timestamp # Store plugin data with timestamp
host.add_plugin_data(plugin_name, plugin_data, timestamp=now) 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 os_info reports an owner and none is configured server-side, apply it
if plugin_name == "os_info": if plugin_name == "os_info":