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, {}) ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")}) 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") tmpl = env.get_template("profile.html")
body = tmpl.render( body = tmpl.render(
title="Profile - Heartbeat", title="Profile - Heartbeat",
@@ -888,6 +890,7 @@ async def start(
managed_hosts=managed, managed_hosts=managed,
monitored_hosts=monitored, monitored_hosts=monitored,
notification_channels=notif_channels, notification_channels=notif_channels,
all_channel_names=all_channel_names,
active_page="profile", active_page="profile",
) )
return web.Response(text=body, content_type="text/html") 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")) templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir)) env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
tmpl = env.get_template("settings.html") tmpl = env.get_template("settings.html")
settings_data = settings_mod.get_settings_data(config, threshold_checker=threshold_checker)
body = tmpl.render( body = tmpl.render(
title="Settings - Heartbeat", 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, current_user=current_user.to_dict() if current_user else None,
active_page="settings", 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", []), "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 [ return [
{ {
"id": "network", "id": "network",
"title": "Network", "title": "Network",
"description": "Ports and bind addresses for all server sockets.", "description": "Ports and bind addresses for all server sockets.",
"section_mode": "form",
"api_section": "server",
"fields": [ "fields": [
field("hb_port", "Heartbeat UDP port", "port", 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", 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", 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", 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", 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", "id": "tls",
"title": "TLS / WebSocket Security", "title": "TLS / WebSocket Security",
"description": "Certificate paths used when wss_port is set.", "description": "Certificate paths used when wss_port is set.",
"section_mode": "form",
"api_section": None,
"fields": [ "fields": [
field("cert_path", "Certificate directory", "path", field("cert_path", "Certificate directory", "path",
"Directory containing the TLS certificate and key files."), "Directory containing the TLS certificate and key files."),
@@ -267,73 +287,89 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "monitoring", "id": "monitoring",
"title": "Monitoring", "title": "Monitoring",
"description": "Heartbeat timing and alert re-notification behaviour.", "description": "Heartbeat timing and alert re-notification behaviour.",
"section_mode": "form",
"api_section": "server",
"fields": [ "fields": [
field("interval", "Heartbeat interval", "duration", 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", 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", 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", field("autosave_interval", "Autosave interval", "duration",
"How often the server saves its state to disk."), "How often the server saves its state to disk."),
field("base_url", "Base URL", "text",
"Base URL for notification links.", editable=True),
], ],
}, },
{ {
"id": "persistence", "id": "persistence",
"title": "Persistence & Logging", "title": "Persistence & Logging",
"description": "State file and event log settings.", "description": "State file and event log settings.",
"section_mode": "form",
"api_section": "server",
"fields": [ "fields": [
field("pickfile", "State file", "path", 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", field("logfile", "Event log", "path",
"Path to the event log file."), "Path to the event log file.", editable=True),
], ],
}, },
{ {
"id": "journal", "id": "journal",
"title": "Message Journal", "title": "Message Journal",
"description": "All received heartbeat and plugin messages are journalled here.", "description": "All received heartbeat and plugin messages are journalled here.",
"section_mode": "form",
"api_section": "server",
"fields": [ "fields": [
field("journal_enabled", "Enabled", "boolean", field("journal_enabled", "Enabled", "boolean",
"Turn journalling on or off."), "Turn journalling on or off.", editable=True),
field("journal_dir", "Journal directory","path", 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", field("journal_file", "Journal filename", "text",
"Base filename for the journal (rotated copies get a numeric suffix)."), "Base filename for the journal (rotated copies get a numeric suffix)."),
field("journal_max_size", "Max file size", "size", 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", 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", "id": "dns",
"title": "Dynamic DNS", "title": "Dynamic DNS",
"description": "nsupdate-based DNS registration for dynamic hosts.", "description": "nsupdate-based DNS registration — edit raw YAML.",
"fields": [ "section_mode": "yaml",
field("nsupdate_bin", "nsupdate binary", "path", "api_section": "dns",
"Full path to the nsupdate executable."), "fields": [],
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."),
],
}, },
{ {
"id": "users", "id": "users",
"title": "Users", "title": "Users",
"description": "Accounts defined in the config file. Password hashes are never shown.", "description": "Accounts defined in the config file. Password hashes are never shown.",
"section_mode": "form",
"api_section": "users",
"users": users_list, "users": users_list,
"fields": [ "fields": [
field("default_owner", "Default owner", "text", field("default_owner", "Default owner", "text",
"Username that owns hosts with no explicit owner. " "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", "id": "channels",
"title": "Notification Channels", "title": "Notification Channels",
"description": "Named notification providers. Credentials are masked.", "description": "Named notification providers. Credentials are masked.",
"section_mode": "yaml",
"api_section": "notification_channels",
"channels": notif_channels, "channels": notif_channels,
"fields": [ "fields": [
field("default_notification_channels", "Default channels", "list", field("default_notification_channels", "Default channels", "list",
@@ -344,6 +380,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "hosts", "id": "hosts",
"title": "Hosts", "title": "Hosts",
"description": "Host definitions loaded from the config file.", "description": "Host definitions loaded from the config file.",
"section_mode": "yaml",
"api_section": "hosts",
"hosts": hosts_list, "hosts": hosts_list,
"fields": [], "fields": [],
}, },
@@ -351,6 +389,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "thresholds", "id": "thresholds",
"title": "Threshold Configurations", "title": "Threshold Configurations",
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.", "description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
"section_mode": "yaml",
"api_section": "thresholds",
"threshold_configs": threshold_config_list, "threshold_configs": threshold_config_list,
"fields": [ "fields": [
field("default_threshold_config", "Default config", "text", field("default_threshold_config", "Default config", "text",
@@ -361,6 +401,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "runtime", "id": "runtime",
"title": "Runtime", "title": "Runtime",
"description": "Flags set at startup (require restart to change).", "description": "Flags set at startup (require restart to change).",
"section_mode": "form",
"api_section": None,
"fields": [ "fields": [
field("foreground", "Foreground mode", "boolean", field("foreground", "Foreground mode", "boolean",
"Run in the foreground instead of daemonising."), "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]