feat: replace YAML editor with form UI for threshold configurations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-05-12 10:57:03 -04:00
parent 668a135e53
commit 1cefc2676e
4 changed files with 297 additions and 3 deletions
+1
View File
@@ -21,6 +21,7 @@ _SERVER_KEYS = [
"interval", "grace", "base_url", "threshold_renotify_interval", "interval", "grace", "base_url", "threshold_renotify_interval",
"logfile", "pidfile", "pickfile", "journal_enabled", "journal_dir", "logfile", "pidfile", "pickfile", "journal_enabled", "journal_dir",
"journal_max_size", "journal_max_backups", "default_owner", "journal_max_size", "journal_max_backups", "default_owner",
"default_threshold_config",
] ]
# Top-level keys managed by the 'dns' logical section # Top-level keys managed by the 'dns' logical section
+76 -1
View File
@@ -25,6 +25,74 @@ logger = logging.getLogger(__name__)
eventlog = notify_mod.eventlog 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: def _render_template(html_str: str, **context) -> str:
tmpl = jinja2.Template(html_str) tmpl = jinja2.Template(html_str)
return tmpl.render(**context) return tmpl.render(**context)
@@ -1228,10 +1296,17 @@ async def start(
attrs.pop("client_secret", None) attrs.pop("client_secret", None)
data["oauth"] = new_oauth data["oauth"] = new_oauth
for section in ("notification_channels", "thresholds", "dns"): for section in ("notification_channels", "dns"):
if section in payload: if section in payload:
configio_mod.apply_yaml_section(data, section, payload[section]) 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: if "hosts" in payload:
h = payload["hosts"] h = payload["hosts"]
if isinstance(h, dict): if isinstance(h, dict):
+3 -2
View File
@@ -247,6 +247,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"hysteresis": tc.hysteresis, "hysteresis": tc.hysteresis,
"count": tc.count, "count": tc.count,
"enabled": tc.enabled, "enabled": tc.enabled,
"display": tc.display or "",
} }
threshold_config_list = [] threshold_config_list = []
@@ -448,12 +449,12 @@ 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", "section_mode": "thresholds",
"api_section": "thresholds", "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",
"Threshold config used for hosts with no explicit mapping."), "Threshold config used for hosts with no explicit mapping.", editable=True),
], ],
}, },
{ {
+217
View File
@@ -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 { 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; } .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 ---- */ /* ---- Multi-picker ---- */
.mpick-wrapper { display: block; } .mpick-wrapper { display: block; }
.mpick-display { .mpick-display {
@@ -689,6 +712,84 @@
<button class="btn btn-primary" onclick="openChannelModal()">+ Add channel</button> <button class="btn btn-primary" onclick="openChannelModal()">+ Add channel</button>
</div> </div>
{# ---- Threshold configurations (form-based) ---- #}
{% elif section.section_mode == 'thresholds' %}
{% for f in section.fields %}
<div class="field-row" style="border-bottom:1px solid #f0f0f0">
<div class="field-label">{{ f.label }}</div>
<div class="field-body">
<input type="text" class="field-input thresh-default-config"
value="{{ f.raw if f.raw is not none else '' }}"
placeholder="default"
list="thresh-cfg-names-{{ section.id }}">
<datalist id="thresh-cfg-names-{{ section.id }}">
{% for tc in section.threshold_configs %}<option value="{{ tc.name | e }}">{% endfor %}
</datalist>
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
</div>
</div>
{% endfor %}
<div id="thresh-cfgs-{{ section.id }}" style="padding:8px 20px 0">
{% for tc in section.threshold_configs %}
<div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}">
<div class="thresh-cfg-header">
<span class="thresh-cfg-name-label">{{ tc.name | e }}</span>
{% if tc.name != 'default' %}
<button class="btn-danger" style="margin-left:auto" onclick="deleteThresholdConfigCard(this)">✕ Delete</button>
{% endif %}
</div>
<div style="overflow-x:auto">
<table class="crud-table thresh-metric-table">
<thead><tr>
<th>Metric path</th><th>Op</th>
<th>Warning</th><th>Critical</th>
<th>Hysteresis</th><th>Count</th>
<th style="max-width:160px">Display</th>
<th>En</th><th></th>
</tr></thead>
<tbody>
{% for m in tc.metrics %}
<tr data-metric-row="true" data-metric-path="{{ m.metric | e }}">
<td style="font-family:monospace;font-size:.85em;white-space:nowrap">{{ m.metric | e }}</td>
<td>
<select class="field-input thresh-op" style="width:80px" onchange="onThreshOpChange(this)">
{% for op in ['>', '>=', '<', '<=', '==', '!=', 'nagios'] %}
<option value="{{ op }}" {% if m.operator == op %}selected{% endif %}>{{ op }}</option>
{% endfor %}
</select>
</td>
<td><input type="number" class="field-input thresh-warn" step="any" style="width:80px"
value="{{ m.warning if m.warning is not none else '' }}"
{% if m.operator == 'nagios' %}disabled{% endif %}></td>
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"
value="{{ m.critical if m.critical is not none else '' }}"
{% if m.operator == 'nagios' %}disabled{% endif %}></td>
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px"
value="{{ m.hysteresis if m.hysteresis is not none else 0.02 }}"></td>
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px"
value="{{ m.count if m.count is not none else 1 }}"></td>
<td><input type="text" class="field-input thresh-display" style="width:150px"
value="{{ m.display | e }}" placeholder="(default)"></td>
<td style="text-align:center"><input type="checkbox" class="thresh-enabled"
{% if m.enabled %}checked{% endif %}></td>
<td><button class="btn-danger" onclick="this.closest('tr').remove()"></button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="padding:6px 14px 8px;border-top:1px solid #f0f0f0">
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
</div>
</div>
{% endfor %}
</div>
<div class="section-footer" style="justify-content:space-between">
<button class="btn btn-secondary" onclick="addThresholdConfigCard('thresh-cfgs-{{ section.id }}')">+ Add config</button>
<button class="btn btn-primary" onclick="stageThresholdsSection('{{ section.id }}')">Stage changes</button>
</div>
{# ---- YAML editor section ---- #} {# ---- YAML editor section ---- #}
{% elif section.section_mode == 'yaml' %} {% elif section.section_mode == 'yaml' %}
<div style="padding: 12px 20px"> <div style="padding: 12px 20px">
@@ -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 '<select class="field-input thresh-op" style="width:80px" onchange="onThreshOpChange(this)">' +
ops.map(op => `<option value="${escHtml(op)}"${op === selected ? ' selected' : ''}>${escHtml(op)}</option>`).join('') +
'</select>';
}
function addThresholdMetricRow(tbody) {
const row = document.createElement('tr');
row.innerHTML = `
<td><input type="text" class="field-input new-metric-path" placeholder="plugin.metric" style="min-width:160px;font-family:monospace;font-size:.85em" required></td>
<td>${_threshOpSelect('>')}</td>
<td><input type="number" class="field-input thresh-warn" step="any" style="width:80px"></td>
<td><input type="number" class="field-input thresh-crit" step="any" style="width:80px"></td>
<td><input type="number" class="field-input thresh-hyst" step="any" style="width:72px" value="0.02"></td>
<td><input type="number" class="field-input thresh-count" step="1" min="1" style="width:52px" value="1"></td>
<td><input type="text" class="field-input thresh-display" style="width:150px" placeholder="(default)"></td>
<td style="text-align:center"><input type="checkbox" class="thresh-enabled" checked></td>
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(row);
}
function addThresholdConfigCard(containerId) {
const container = document.getElementById(containerId);
const card = document.createElement('div');
card.className = 'thresh-cfg-card';
card.innerHTML = `
<div class="thresh-cfg-header">
<input type="text" class="field-input new-config-name" placeholder="Config name (e.g. servers)" style="max-width:220px">
<button class="btn-danger" style="margin-left:auto" onclick="this.closest('.thresh-cfg-card').remove()">✕ Delete</button>
</div>
<div style="overflow-x:auto">
<table class="crud-table thresh-metric-table">
<thead><tr>
<th>Metric path</th><th>Op</th>
<th>Warning</th><th>Critical</th>
<th>Hysteresis</th><th>Count</th>
<th style="max-width:160px">Display</th>
<th>En</th><th></th>
</tr></thead>
<tbody></tbody>
</table>
</div>
<div style="padding:6px 14px 8px;border-top:1px solid #f0f0f0">
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
</div>`;
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() { function closeSidebar() {
var sidebarNav = document.getElementById('sidebar-nav'); var sidebarNav = document.getElementById('sidebar-nav');
var sidebarToggle = document.getElementById('sidebar-toggle'); var sidebarToggle = document.getElementById('sidebar-toggle');