From 8640d731aa3edb68685e08411702d9999b4f46a1 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Sat, 9 May 2026 11:49:41 -0400 Subject: [PATCH] feat: add section_mode, api_section, editable flags and oauth section to settings Co-Authored-By: Claude Sonnet 4.6 --- hbd/server/http.py | 7 ++- hbd/server/settings.py | 97 +++++++++++++++++++++++++-------- tests/test_settings_sections.py | 83 ++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 25 deletions(-) create mode 100644 tests/test_settings_sections.py diff --git a/hbd/server/http.py b/hbd/server/http.py index aeae835..bf5e7c6 100644 --- a/hbd/server/http.py +++ b/hbd/server/http.py @@ -879,6 +879,8 @@ async def start( ch_cfg = config.get("notification_channels", {}).get(ch_name, {}) notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")}) + all_channel_names = sorted((config.get("notification_channels") or {}).keys()) + tmpl = env.get_template("profile.html") body = tmpl.render( title="Profile - Heartbeat", @@ -888,6 +890,7 @@ async def start( managed_hosts=managed, monitored_hosts=monitored, notification_channels=notif_channels, + all_channel_names=all_channel_names, active_page="profile", ) return web.Response(text=body, content_type="text/html") @@ -947,9 +950,11 @@ async def start( templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates")) env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir)) tmpl = env.get_template("settings.html") + settings_data = settings_mod.get_settings_data(config, threshold_checker=threshold_checker) body = tmpl.render( title="Settings - Heartbeat", - sections=settings_mod.get_settings_sections(config, threshold_checker=threshold_checker), + sections=settings_data["sections"], + all_channel_names=settings_data["all_channel_names"], current_user=current_user.to_dict() if current_user else None, active_page="settings", ) diff --git a/hbd/server/settings.py b/hbd/server/settings.py index b442890..8fa6433 100644 --- a/hbd/server/settings.py +++ b/hbd/server/settings.py @@ -232,28 +232,48 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: "notification_channels": hcfg.get("notification_channels", []), }) + # ---- OAuth providers ------------------------------------------------------- + oauth_providers = [] + for pname, pattrs in (config.get("oauth") or {}).items(): + if not isinstance(pattrs, dict): + continue + cs = pattrs.get("client_secret", "") + oauth_providers.append({ + "name": pname, + "type": pattrs.get("type", "gitea"), + "url": pattrs.get("url", ""), + "client_id": pattrs.get("client_id", ""), + "client_secret": "•••" if cs else "", + "label": pattrs.get("label", ""), + "logo": pattrs.get("logo", ""), + }) + return [ { "id": "network", "title": "Network", "description": "Ports and bind addresses for all server sockets.", + "section_mode": "form", + "api_section": "server", "fields": [ field("hb_port", "Heartbeat UDP port", "port", - "UDP port the server listens on for heartbeat datagrams."), + "UDP port the server listens on for heartbeat datagrams.", editable=True), field("hbd_host", "HTTP bind address", "text", - "Interface to bind the HTTP server to. Empty = all interfaces."), + "Interface to bind the HTTP server to. Empty = all interfaces.", editable=True), field("hbd_port", "HTTP API port", "port", - "TCP port for the HTTP API and web UI."), + "TCP port for the HTTP API and web UI.", editable=True), field("ws_port", "WebSocket port", "port", - "TCP port for the plain WebSocket server."), + "TCP port for the plain WebSocket server.", editable=True), field("wss_port", "Secure WebSocket port", "port", - "TCP port for WSS (TLS WebSocket). Leave empty to disable."), + "TCP port for WSS (TLS WebSocket). Leave empty to disable.", editable=True), ], }, { "id": "tls", "title": "TLS / WebSocket Security", "description": "Certificate paths used when wss_port is set.", + "section_mode": "form", + "api_section": None, "fields": [ field("cert_path", "Certificate directory", "path", "Directory containing the TLS certificate and key files."), @@ -267,73 +287,89 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: "id": "monitoring", "title": "Monitoring", "description": "Heartbeat timing and alert re-notification behaviour.", + "section_mode": "form", + "api_section": "server", "fields": [ field("interval", "Heartbeat interval", "duration", - "Expected time between heartbeat messages from each client."), + "Expected time between heartbeat messages from each client.", editable=True), field("grace", "Grace multiplier", "number", - "A host is marked overdue after interval × grace seconds of silence."), + "A host is marked overdue after interval × grace seconds of silence.", editable=True), field("threshold_renotify_interval", "Re-notify interval", "duration", - "How often to re-send notifications for ongoing threshold alerts."), + "How often to re-send notifications for ongoing threshold alerts.", editable=True), field("autosave_interval", "Autosave interval", "duration", "How often the server saves its state to disk."), + field("base_url", "Base URL", "text", + "Base URL for notification links.", editable=True), ], }, { "id": "persistence", "title": "Persistence & Logging", "description": "State file and event log settings.", + "section_mode": "form", + "api_section": "server", "fields": [ field("pickfile", "State file", "path", - "Path to the pickle file used to persist host state across restarts."), + "Path to the pickle file used to persist host state across restarts.", editable=True), field("logfile", "Event log", "path", - "Path to the event log file."), + "Path to the event log file.", editable=True), ], }, { "id": "journal", "title": "Message Journal", "description": "All received heartbeat and plugin messages are journalled here.", + "section_mode": "form", + "api_section": "server", "fields": [ field("journal_enabled", "Enabled", "boolean", - "Turn journalling on or off."), + "Turn journalling on or off.", editable=True), field("journal_dir", "Journal directory","path", - "Directory where journal files are written."), + "Directory where journal files are written.", editable=True), field("journal_file", "Journal filename", "text", "Base filename for the journal (rotated copies get a numeric suffix)."), field("journal_max_size", "Max file size", "size", - "Rotate the journal when it exceeds this size."), + "Rotate the journal when it exceeds this size.", editable=True), field("journal_max_backups", "Backup count", "number", - "Number of rotated journal files to keep."), + "Number of rotated journal files to keep.", editable=True), ], }, { "id": "dns", "title": "Dynamic DNS", - "description": "nsupdate-based DNS registration for dynamic hosts.", - "fields": [ - field("nsupdate_bin", "nsupdate binary", "path", - "Full path to the nsupdate executable."), - field("dyndomains", "Dynamic domains", "list", - "DNS zones managed by nsupdate for dynamic hosts."), - field("drophosts", "Drop hosts", "list", - "Hostnames to silently ignore — no state, no alerts."), - ], + "description": "nsupdate-based DNS registration — edit raw YAML.", + "section_mode": "yaml", + "api_section": "dns", + "fields": [], }, { "id": "users", "title": "Users", "description": "Accounts defined in the config file. Password hashes are never shown.", + "section_mode": "form", + "api_section": "users", "users": users_list, "fields": [ field("default_owner", "Default owner", "text", "Username that owns hosts with no explicit owner. " - "Falls back to the first admin user."), + "Falls back to the first admin user.", editable=True), ], }, + { + "id": "oauth", + "title": "OAuth Providers", + "description": "OAuth2 login providers. Client secrets are masked.", + "section_mode": "form", + "api_section": "oauth", + "providers": oauth_providers, + "fields": [], + }, { "id": "channels", "title": "Notification Channels", "description": "Named notification providers. Credentials are masked.", + "section_mode": "yaml", + "api_section": "notification_channels", "channels": notif_channels, "fields": [ field("default_notification_channels", "Default channels", "list", @@ -344,6 +380,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: "id": "hosts", "title": "Hosts", "description": "Host definitions loaded from the config file.", + "section_mode": "yaml", + "api_section": "hosts", "hosts": hosts_list, "fields": [], }, @@ -351,6 +389,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: "id": "thresholds", "title": "Threshold Configurations", "description": "Named alert threshold sets. Each defines warning/critical levels per metric.", + "section_mode": "yaml", + "api_section": "thresholds", "threshold_configs": threshold_config_list, "fields": [ field("default_threshold_config", "Default config", "text", @@ -361,6 +401,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: "id": "runtime", "title": "Runtime", "description": "Flags set at startup (require restart to change).", + "section_mode": "form", + "api_section": None, "fields": [ field("foreground", "Foreground mode", "boolean", "Run in the foreground instead of daemonising."), @@ -371,3 +413,10 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: ], }, ] + + +def get_settings_data(config: dict, threshold_checker=None) -> dict: + """Return sections list + auxiliary data for the settings template.""" + sections = get_settings_sections(config, threshold_checker=threshold_checker) + all_channel_names = sorted((config.get("notification_channels") or {}).keys()) + return {"sections": sections, "all_channel_names": all_channel_names} diff --git a/tests/test_settings_sections.py b/tests/test_settings_sections.py new file mode 100644 index 0000000..1fa8a5e --- /dev/null +++ b/tests/test_settings_sections.py @@ -0,0 +1,83 @@ +import pytest +from hbd.server import settings as settings_mod + +CFG = { + "hbd_port": 50004, + "interval": 20, + "grace": 2, + "users": { + "alice": {"full_name": "Alice Smith", "admin": True, "password": "pbkdf2:sha256:abc", + "notification_channels": ["pushover_ops"]}, + }, + "oauth": { + "gitea": {"type": "gitea", "url": "https://git.example.com", + "client_id": "cid", "client_secret": "csec", "label": "Sign in with Gitea"}, + }, + "notification_channels": { + "pushover_ops": {"type": "pushover", "token": "tok", "user": "usr"}, + }, + "hosts": {}, +} + + +def test_sections_have_section_mode(): + sections = settings_mod.get_settings_sections(CFG) + for s in sections: + assert "section_mode" in s, f"Section {s['id']} missing section_mode" + assert s["section_mode"] in ("form", "yaml") + + +def test_sections_have_api_section(): + sections = settings_mod.get_settings_sections(CFG) + for s in sections: + assert "api_section" in s, f"Section {s['id']} missing api_section" + + +def test_network_section_has_editable_fields(): + sections = settings_mod.get_settings_sections(CFG) + network = next(s for s in sections if s["id"] == "network") + assert network["section_mode"] == "form" + assert network["api_section"] == "server" + editable = [f for f in network["fields"] if f["editable"]] + assert len(editable) >= 2 # hbd_port, ws_port at minimum + + +def test_yaml_sections_have_correct_mode(): + sections = settings_mod.get_settings_sections(CFG) + yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"} + assert "channels" in yaml_sections + assert "hosts" in yaml_sections + assert "thresholds" in yaml_sections + assert "dns" in yaml_sections + assert yaml_sections["channels"]["api_section"] == "notification_channels" + assert yaml_sections["hosts"]["api_section"] == "hosts" + assert yaml_sections["thresholds"]["api_section"] == "thresholds" + assert yaml_sections["dns"]["api_section"] == "dns" + + +def test_oauth_section_exists(): + sections = settings_mod.get_settings_sections(CFG) + oauth = next((s for s in sections if s["id"] == "oauth"), None) + assert oauth is not None + assert oauth["section_mode"] == "form" + assert oauth["api_section"] == "oauth" + assert len(oauth["providers"]) == 1 + assert oauth["providers"][0]["name"] == "gitea" + assert oauth["providers"][0]["client_secret"] == "•••" + + +def test_all_channel_names_returned(): + result = settings_mod.get_settings_data(CFG) + assert "all_channel_names" in result + assert "pushover_ops" in result["all_channel_names"] + + +def test_users_section_has_user_list(): + sections = settings_mod.get_settings_sections(CFG) + users_sec = next(s for s in sections if s["id"] == "users") + assert users_sec["section_mode"] == "form" + assert users_sec["api_section"] == "users" + assert len(users_sec["users"]) == 1 + assert users_sec["users"][0]["username"] == "alice" + # Password hash never exposed + assert "password" not in users_sec["users"][0]