feat: add section_mode, api_section, editable flags and oauth section to settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 11:49:41 -04:00
parent de81751e59
commit 8640d731aa
3 changed files with 162 additions and 25 deletions
+6 -1
View File
@@ -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",
)
+73 -24
View File
@@ -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}
+83
View File
@@ -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]