diff --git a/hbd/server/configio.py b/hbd/server/configio.py index 4dc85a9..b5902cc 100644 --- a/hbd/server/configio.py +++ b/hbd/server/configio.py @@ -21,6 +21,7 @@ _SERVER_KEYS = [ "interval", "grace", "base_url", "threshold_renotify_interval", "logfile", "pidfile", "pickfile", "journal_enabled", "journal_dir", "journal_max_size", "journal_max_backups", "default_owner", + "default_threshold_config", ] # Top-level keys managed by the 'dns' logical section diff --git a/hbd/server/http.py b/hbd/server/http.py index e05c4c9..5036bbf 100644 --- a/hbd/server/http.py +++ b/hbd/server/http.py @@ -25,6 +25,74 @@ logger = logging.getLogger(__name__) eventlog = notify_mod.eventlog + +def _build_threshold_configs_from_form(form_data: dict) -> dict: + """Convert form-submitted flat threshold data to nested threshold_configs YAML structure. + + Input: {config_name: {metric_path: {warning, critical, operator, hysteresis, enabled, count, display}}} + Output: {config_name: {thresholds: {plugin: {metric: {warning, critical, ...}}}}} + """ + result = {} + for config_name, metrics in form_data.items(): + if not isinstance(metrics, dict): + continue + thresholds = {} + for metric_path, values in metrics.items(): + _insert_threshold_metric(thresholds, metric_path, values) + result[config_name] = {"thresholds": thresholds} + return result + + +def _insert_threshold_metric(thresholds: dict, metric_path: str, values: dict) -> None: + """Insert a single metric into the nested threshold YAML structure.""" + if not isinstance(values, dict): + return + + cfg = {} + op = values.get("operator", ">") + if op and op != ">": + cfg["operator"] = op + + for key, cast in (("warning", float), ("critical", float), ("hysteresis", float)): + v = values.get(key) + if v is not None: + try: + cfg[key] = cast(v) + except (TypeError, ValueError): + pass + + count = values.get("count") + if count is not None: + try: + cfg["count"] = int(count) + except (TypeError, ValueError): + pass + + display = values.get("display", "") + if display: + cfg["display"] = display + + if not values.get("enabled", True): + cfg["enabled"] = False + + parts = metric_path.split(".", 2) + + if len(parts) == 1: + # e.g. "rtt" + thresholds[metric_path] = cfg + elif len(parts) == 2: + plugin, metric = parts + thresholds.setdefault(plugin, {})[metric] = cfg + else: + plugin, intermediate, leaf = parts + thresholds.setdefault(plugin, {}) + if plugin == "disk_monitor": + thresholds[plugin].setdefault("partitions", {}).setdefault(intermediate, {})[leaf] = cfg + elif plugin == "zfs_monitor": + thresholds[plugin].setdefault("pools", {}).setdefault(intermediate, {})[leaf] = cfg + else: + thresholds[plugin].setdefault(intermediate, {})[leaf] = cfg + def _render_template(html_str: str, **context) -> str: tmpl = jinja2.Template(html_str) return tmpl.render(**context) @@ -1228,10 +1296,17 @@ async def start( attrs.pop("client_secret", None) data["oauth"] = new_oauth - for section in ("notification_channels", "thresholds", "dns"): + for section in ("notification_channels", "dns"): if section in payload: configio_mod.apply_yaml_section(data, section, payload[section]) + if "thresholds" in payload: + tc = payload["thresholds"] + if isinstance(tc, str): + configio_mod.apply_yaml_section(data, "thresholds", tc) + elif isinstance(tc, dict): + data["threshold_configs"] = _build_threshold_configs_from_form(tc) + if "hosts" in payload: h = payload["hosts"] if isinstance(h, dict): diff --git a/hbd/server/settings.py b/hbd/server/settings.py index 5200ef8..aef014d 100644 --- a/hbd/server/settings.py +++ b/hbd/server/settings.py @@ -247,6 +247,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list: "hysteresis": tc.hysteresis, "count": tc.count, "enabled": tc.enabled, + "display": tc.display or "", } threshold_config_list = [] @@ -448,12 +449,12 @@ 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", + "section_mode": "thresholds", "api_section": "thresholds", "threshold_configs": threshold_config_list, "fields": [ field("default_threshold_config", "Default config", "text", - "Threshold config used for hosts with no explicit mapping."), + "Threshold config used for hosts with no explicit mapping.", editable=True), ], }, { diff --git a/hbd/server/templates/settings.html b/hbd/server/templates/settings.html index 1f35e89..49e0d28 100644 --- a/hbd/server/templates/settings.html +++ b/hbd/server/templates/settings.html @@ -383,6 +383,29 @@ .backup-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: .87em; } .backup-row:last-child { border-bottom: none; } + /* ---- Threshold config cards ---- */ + .thresh-cfg-card { + margin-bottom: 14px; + border: 1px solid #e0e0e0; + border-radius: 6px; + overflow: hidden; + } + .thresh-cfg-header { + background: #f5f5f5; + padding: 8px 14px; + display: flex; + align-items: center; + gap: 10px; + border-bottom: 1px solid #e0e0e0; + } + .thresh-cfg-name-label { + font-weight: 600; + font-size: 0.9em; + color: #1a237e; + } + .thresh-metric-table { width: 100%; } + .thresh-metric-table th { white-space: nowrap; } + /* ---- Multi-picker ---- */ .mpick-wrapper { display: block; } .mpick-display { @@ -689,6 +712,84 @@ + {# ---- Threshold configurations (form-based) ---- #} + {% elif section.section_mode == 'thresholds' %} + {% for f in section.fields %} +
+
{{ f.label }}
+
+ + + {% for tc in section.threshold_configs %} + {% if f.description %}

{{ f.description }}

{% endif %} +
+
+ {% endfor %} +
+ {% for tc in section.threshold_configs %} +
+
+ {{ tc.name | e }} + {% if tc.name != 'default' %} + + {% endif %} +
+
+ + + + + + + + + + {% for m in tc.metrics %} + + + + + + + + + + + + {% endfor %} + +
Metric pathOpWarningCriticalHysteresisCountDisplayEn
{{ m.metric | e }} + +
+
+
+ +
+
+ {% endfor %} +
+ + {# ---- YAML editor section ---- #} {% elif section.section_mode == 'yaml' %}
@@ -1351,6 +1452,122 @@ }); } + // ---- Threshold configurations form ---- + function stageThresholdsSection(sectionId) { + const section = document.getElementById(sectionId); + const configs = {}; + + function readMetrics(card) { + const metrics = {}; + card.querySelectorAll('tbody tr').forEach(row => { + if (row.dataset.deleted === 'true') return; + const metric = row.dataset.metricPath + || (row.querySelector('.new-metric-path')?.value || '').trim(); + if (!metric) return; + const op = row.querySelector('.thresh-op')?.value || '>'; + const warn = row.querySelector('.thresh-warn')?.value; + const crit = row.querySelector('.thresh-crit')?.value; + const hyst = row.querySelector('.thresh-hyst')?.value; + const count = row.querySelector('.thresh-count')?.value; + const display = row.querySelector('.thresh-display')?.value || ''; + const enabled = row.querySelector('.thresh-enabled')?.checked ?? true; + const entry = { operator: op, enabled: enabled }; + if (warn !== '' && warn !== undefined) entry.warning = parseFloat(warn); + if (crit !== '' && crit !== undefined) entry.critical = parseFloat(crit); + if (hyst !== '' && hyst !== undefined) entry.hysteresis = parseFloat(hyst); + if (count !== '' && count !== undefined) entry.count = parseInt(count, 10); + if (display) entry.display = display; + metrics[metric] = entry; + }); + return metrics; + } + + const cfgsContainer = document.getElementById('thresh-cfgs-' + sectionId); + cfgsContainer.querySelectorAll('.thresh-cfg-card').forEach(card => { + const configName = card.dataset.configName + || (card.querySelector('.new-config-name')?.value || '').trim(); + if (!configName) return; + configs[configName] = readMetrics(card); + }); + + _staged['thresholds'] = configs; + + const defInput = section.querySelector('.thresh-default-config'); + if (defInput) { + if (!_staged['server']) _staged['server'] = {}; + _staged['server']['default_threshold_config'] = defInput.value || 'default'; + } + + updatePendingBanner(); + flashStaged(sectionId); + } + + function onThreshOpChange(select) { + const row = select.closest('tr'); + const isNagios = select.value === 'nagios'; + const w = row.querySelector('.thresh-warn'); + const c = row.querySelector('.thresh-crit'); + if (w) w.disabled = isNagios; + if (c) c.disabled = isNagios; + } + + function _threshOpSelect(selected) { + const ops = ['>', '>=', '<', '<=', '==', '!=', 'nagios']; + return ''; + } + + function addThresholdMetricRow(tbody) { + const row = document.createElement('tr'); + row.innerHTML = ` + + ${_threshOpSelect('>')} + + + + + + + `; + tbody.appendChild(row); + } + + function addThresholdConfigCard(containerId) { + const container = document.getElementById(containerId); + const card = document.createElement('div'); + card.className = 'thresh-cfg-card'; + card.innerHTML = ` +
+ + +
+
+ + + + + + + + + +
Metric pathOpWarningCriticalHysteresisCountDisplayEn
+
+
+ +
`; + container.appendChild(card); + } + + function deleteThresholdConfigCard(btn) { + const card = btn.closest('.thresh-cfg-card'); + const name = card.dataset.configName || 'this config'; + if (!confirm(`Delete config "${name}"?`)) return; + card.remove(); + } + function closeSidebar() { var sidebarNav = document.getElementById('sidebar-nav'); var sidebarToggle = document.getElementById('sidebar-toggle');