Compare commits

...

11 Commits

Author SHA1 Message Date
andreas d7b5c97a4e version 5.1.21
Release / release (push) Successful in 6s
2026-05-05 11:05:48 -04:00
andreas ae447ac4a6 feat: nagios_runner improvements and alerts page fixes
- nagios_runner: remove overall_status/overall_status_code/plugin_count fields;
  each command still reports its own <name>_status and <name>_status_code
- threshold: expose {output} and {status} aliases in display templates for
  nagios_runner generic matches (mapped from <check_name>_output/status)
- alerts.html: fix scrolling by overriding html,body height/overflow (style.css
  sets both); make hostname a link to /plugins/<hostname>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:05:45 -04:00
andreas d44ce3d124 version 5.1.20
Release / release (push) Successful in 6s
2026-05-05 10:48:24 -04:00
andreas b1985d0eb2 feat: generic threshold matching for nagios_runner with {check_name} display support
_find_threshold() now returns the stripped prefix ("check_name") alongside
the ThresholdConfig, enabling a single generic entry (e.g. nagios_runner.status_code)
to cover all per-command metrics (check_disk_root_status_code, check_load_status_code,
…). The prefix is threaded through to _format_display() as {check_name}, with
{metric_name} also available in display templates. purge_stale_alerts() updated
to use generic matching so it does not incorrectly drop alerts on generic-matched
metrics. README updated with Display Format Templates and Generic Threshold
Matching sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 10:48:17 -04:00
andreas de778f680f fix: reduce default hysteresis 10%→2%; show recovery threshold in alerts UI
The 10% default hysteresis created an unreasonably wide recovery band:
a 95% threshold would only clear once the value dropped below 85.5%,
causing alerts to linger long after the metric was well below the
trigger level.

Change default hysteresis to 2% across all threshold parsers (plugin
metrics, partitions, RTT). For a 95% threshold, recovery is now at
93.1% instead of 85.5%.

Add AlertState.hysteresis field (set on every check, cleared on OK) and
expose recovery_threshold in to_dict() so the Alerts dashboard can
display "recovers < 93.1" alongside the trigger threshold, making the
hysteresis band visible to the user. Pickle backward-compatible via
__setstate__.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 14:47:50 -04:00
andreas d7b368c7c6 version 5.1.19
Release / release (push) Successful in 5s
2026-05-04 12:10:01 -04:00
andreas e790663f9f feat: exclude ZFS ARC from memory_percent; add uptime_seconds to cpu_monitor
memory_monitor / hbc_mini: ZFS ARC is reclaimable but not reflected in
MemAvailable by the Linux kernel (not in SReclaimable). Read ARC size
from /proc/spl/kstat/zfs/arcstats and add it to available memory before
computing memory_percent and memory_used. No-op on systems without ZFS.

cpu_monitor: report uptime_seconds via psutil.boot_time() (full client)
and /proc/uptime (hbc_mini).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 12:09:58 -04:00
andreas 475319e248 fix: send boot/shutdown on first open connection, not blindly first in list
Replace break-after-first-iteration with next(c for c in connections if
c.transport) so the message goes to the first connection that actually
has an open transport. Falls back to connections[0] if none are open
yet (sendto will attempt reopen), avoiding silent message loss when the
leading connection is still connecting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:59:30 -04:00
andreas ca5ef384a8 version 5.1.18
Release / release (push) Successful in 5s
2026-05-04 09:13:18 -04:00
andreas c93dbdc0f4 fix: settings thresholds show correct per-config metrics; misc hbc fixes
Settings page: pass threshold_checker to http.start so the Threshold
Configurations section has data. Use threshold_checker's already-parsed
ThresholdConfig objects instead of re-parsing the raw nested YAML.
Named (non-default) configs now display only their explicit overrides
via threshold_raw_configs, not the full merged set with defaults.

hbc/hbc_mini: send boot and shutdown messages on first connection only
to avoid duplicate packets when multiple servers are configured.
Replace print("Daemonizing...") with logging.info so output goes to
syslog in daemon mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:12:39 -04:00
andreas 3a546a1e5c feat: fetch-based Update/Delete buttons with toast notification on Host Overview
Replace href navigation with fetch() so the server response is captured
and displayed in a slide-up toast at the bottom of the page. Delete also
removes the host card from the DOM on success without a page reload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 08:16:54 -04:00
14 changed files with 370 additions and 137 deletions
+57 -2
View File
@@ -181,7 +181,8 @@ thresholds:
warning: 80.0 # Warn when CPU > 80% warning: 80.0 # Warn when CPU > 80%
critical: 90.0 # Critical when CPU > 90% critical: 90.0 # Critical when CPU > 90%
operator: ">" operator: ">"
hysteresis: 0.1 # 10% hysteresis to prevent flapping hysteresis: 0.02 # 2% hysteresis to prevent flapping
display: "(threshold: {op_symbol} {threshold_value}%)" # optional
memory_monitor: memory_monitor:
percent: percent:
@@ -274,7 +275,61 @@ All plugin metrics can be thresholded:
- **Memory**: percent, available_mb, swap_percent - **Memory**: percent, available_mb, swap_percent
- **Disk**: Per-partition percent, free_gb, free_mb - **Disk**: Per-partition percent, free_gb, free_mb
- **Network**: errors_total, dropped packets, connection counts - **Network**: errors_total, dropped packets, connection counts
- **Nagios**: exit_code mapping (0=OK, 1=WARNING, 2=CRITICAL) - **Nagios**: Any field emitted by `nagios_runner` (status_code, exit_code, performance data, …)
### Display Format Templates
Each threshold entry accepts an optional `display` field — a Python format string shown in notifications and on the Alerts dashboard:
```yaml
nagios_runner:
status_code:
warning: 1
critical: 2
operator: ">="
display: "{check_name}: exit {value} (expected < {threshold_value})"
```
Available variables:
| Variable | Description |
|---|---|
| `{value}` | Current metric value |
| `{threshold_value}` | Threshold that was crossed |
| `{op_symbol}` | Comparison operator (`>`, `<`, `>=`, …) |
| `{check_name}` | Prefix stripped by generic matching (see below) |
| `{metric_name}` | Full field name within the plugin data |
| `{output}` | For `nagios_runner` generic matches: the matched check's status text (alias for `{check_name}_output`) |
| `{status}` | For `nagios_runner` generic matches: the matched check's status name — OK/WARNING/CRITICAL/UNKNOWN (alias for `{check_name}_status`) |
| any plugin field | Any other field present in the plugin's data |
### Generic Threshold Matching
When a metric name has no exact threshold entry, the server progressively strips leading underscore-separated segments and re-tries the lookup. This lets a single generic entry cover an entire family of metrics.
The classic use case is `nagios_runner`, which names each metric after the command that produced it:
```
nagios_runner.check_disk_root_status_code → no exact match
nagios_runner.disk_root_status_code → no match
nagios_runner.root_status_code → no match
nagios_runner.status_code → matched ✓
```
Configure the generic threshold once:
```yaml
nagios_runner:
status_code:
warning: 1
critical: 2
operator: ">="
display: "{check_name}: exit {value}"
```
The stripped prefix (`check_disk_root` in the example above) is available as `{check_name}` in the display template, so you can identify which check triggered the alert without writing a separate threshold entry per command.
Exact matches always take priority. A generic entry only applies when no specific one is defined.
### Per-Host Threshold Profiles ### Per-Host Threshold Profiles
+1 -1
View File
@@ -14,4 +14,4 @@ Install options:
""" """
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "5.1.17" __version__ = "5.1.21"
+7 -10
View File
@@ -463,16 +463,13 @@ async def cleanup(connections: List[AsyncConnection]):
logger = logging.getLogger("hbc.cleanup") logger = logging.getLogger("hbc.cleanup")
logger.info("Cleaning up connections") logger.info("Cleaning up connections")
for conn in connections: target = next((c for c in connections if c.transport), connections[0] if connections else None)
if target:
try: try:
msg = { await target.sendto({"shutdown": 1, "acks": target.ackcount})
"shutdown": 1,
"acks": conn.ackcount
}
await conn.sendto(msg)
except Exception as e: except Exception as e:
logger.error(f"Error sending shutdown: {e}") logger.error(f"Error sending shutdown: {e}")
for conn in connections:
conn.close() conn.close()
# Give messages time to send # Give messages time to send
@@ -538,8 +535,8 @@ async def async_main(args, config):
boot_msg["msg"] = args.message boot_msg["msg"] = args.message
boot_msg["acks"] = 0 boot_msg["acks"] = 0
for conn in connections: target = next((c for c in connections if c.transport), connections[0])
await conn.sendto(boot_msg) await target.sendto(boot_msg)
if args.message and not args.daemon: if args.message and not args.daemon:
# Message-only mode # Message-only mode
@@ -739,7 +736,7 @@ def main(argv=None):
# Daemonize if requested # Daemonize if requested
if args.daemon: if args.daemon:
print("Daemonizing...") logging.info("Daemonizing...")
daemonize() daemonize()
_reconfigure_logging_for_daemon(log_level) _reconfigure_logging_for_daemon(log_level)
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}") logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
+7
View File
@@ -119,6 +119,13 @@ class CPUMonitorPlugin(MonitorPlugin):
except Exception as e: except Exception as e:
self.logger.debug(f"Could not get CPU times: {e}") self.logger.debug(f"Could not get CPU times: {e}")
# Uptime in seconds
try:
import time
data["uptime_seconds"] = int(time.time() - self.psutil.boot_time())
except Exception as e:
self.logger.debug(f"Could not get uptime: {e}")
self.logger.debug( self.logger.debug(
f"Collected CPU metrics: {data.get('cpu_percent', 'N/A')}% usage" f"Collected CPU metrics: {data.get('cpu_percent', 'N/A')}% usage"
) )
+31 -3
View File
@@ -14,6 +14,24 @@ except ImportError:
from hbd.client.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
def _zfs_arc_bytes() -> int:
"""Return current ZFS ARC size in bytes, or 0 if ZFS is not present.
ZFS ARC is reclaimable but is not included in MemAvailable by the Linux
kernel (it is not in SReclaimable), so it would otherwise be counted as
used memory.
"""
try:
with open("/proc/spl/kstat/zfs/arcstats") as fh:
for line in fh:
parts = line.split()
if len(parts) >= 3 and parts[0] == "size":
return int(parts[2])
except (OSError, ValueError):
pass
return 0
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -101,11 +119,21 @@ class MemoryMonitorPlugin(MonitorPlugin):
# Virtual (physical) memory statistics # Virtual (physical) memory statistics
vmem = psutil.virtual_memory() vmem = psutil.virtual_memory()
# psutil's available already excludes page cache / file buffers
# (uses MemAvailable on Linux). Add ZFS ARC on top because the kernel
# does not include it in SReclaimable / MemAvailable even though it is
# reclaimable.
arc_bytes = _zfs_arc_bytes()
available = min(vmem.available + arc_bytes, vmem.total)
used = vmem.total - available
percent = round(used / vmem.total * 100, 1) if vmem.total else 0.0
metrics['memory_total'] = vmem.total metrics['memory_total'] = vmem.total
metrics['memory_available'] = vmem.available metrics['memory_available'] = available
metrics['memory_used'] = vmem.used metrics['memory_used'] = used
metrics['memory_free'] = vmem.free metrics['memory_free'] = vmem.free
metrics['memory_percent'] = vmem.percent metrics['memory_percent'] = percent
# Platform-specific memory details # Platform-specific memory details
if hasattr(vmem, 'active'): if hasattr(vmem, 'active'):
+4 -20
View File
@@ -31,16 +31,13 @@ from hbd.client.plugin import MonitorPlugin
# Nagios exit codes # Nagios exit codes
NAGIOS_OK = 0
NAGIOS_WARNING = 1
NAGIOS_CRITICAL = 2
NAGIOS_UNKNOWN = 3 NAGIOS_UNKNOWN = 3
STATUS_NAMES = { STATUS_NAMES = {
NAGIOS_OK: "OK", 0: "OK",
NAGIOS_WARNING: "WARNING", 1: "WARNING",
NAGIOS_CRITICAL: "CRITICAL", 2: "CRITICAL",
NAGIOS_UNKNOWN: "UNKNOWN" 3: "UNKNOWN",
} }
@@ -129,9 +126,6 @@ class NagiosRunnerPlugin(MonitorPlugin):
""" """
results = {} results = {}
# Track overall status (worst status wins)
worst_status = NAGIOS_OK
for cmd_config in self.commands: for cmd_config in self.commands:
name = cmd_config.get("name") name = cmd_config.get("name")
command = cmd_config.get("command") command = cmd_config.get("command")
@@ -149,10 +143,6 @@ class NagiosRunnerPlugin(MonitorPlugin):
results[f"{name}_status_code"] = status_code results[f"{name}_status_code"] = status_code
results[f"{name}_output"] = output results[f"{name}_output"] = output
# Track worst status
if status_code > worst_status:
worst_status = status_code
# Parse and add performance data # Parse and add performance data
if perfdata: if perfdata:
for metric_name, metric_value in perfdata.items(): for metric_name, metric_value in perfdata.items():
@@ -167,12 +157,6 @@ class NagiosRunnerPlugin(MonitorPlugin):
results[f"{name}_status"] = "ERROR" results[f"{name}_status"] = "ERROR"
results[f"{name}_status_code"] = NAGIOS_UNKNOWN results[f"{name}_status_code"] = NAGIOS_UNKNOWN
results[f"{name}_output"] = str(e) results[f"{name}_output"] = str(e)
worst_status = NAGIOS_UNKNOWN
# Add overall status
results["overall_status"] = STATUS_NAMES.get(worst_status, "UNKNOWN")
results["overall_status_code"] = worst_status
results["plugin_count"] = len(self.commands)
return results return results
+1 -1
View File
@@ -890,7 +890,7 @@ async def start(
tmpl = env.get_template("settings.html") tmpl = env.get_template("settings.html")
body = tmpl.render( body = tmpl.render(
title="Settings - Heartbeat", title="Settings - Heartbeat",
sections=settings_mod.get_settings_sections(config), sections=settings_mod.get_settings_sections(config, threshold_checker=threshold_checker),
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",
) )
+1
View File
@@ -255,6 +255,7 @@ async def _run_async(config, config_path=None):
config=config, config=config,
hbdclass=hbdclass, hbdclass=hbdclass,
tcss=None, tcss=None,
threshold_checker=threshold_checker,
verbose=config.get("verbose", False), verbose=config.get("verbose", False),
get_now=lambda: time.time(), get_now=lambda: time.time(),
VER="", VER="",
+30 -37
View File
@@ -88,7 +88,7 @@ def _sanitize_channel(name, cfg):
# Public API # Public API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def get_settings_sections(config: dict) -> list: def get_settings_sections(config: dict, threshold_checker=None) -> list:
"""Return ordered list of setting sections for the settings page. """Return ordered list of setting sections for the settings page.
Each section: Each section:
@@ -182,46 +182,39 @@ def get_settings_sections(config: dict) -> list:
}) })
# ---- Threshold configurations ----------------------------------------- # ---- Threshold configurations -----------------------------------------
def _parse_metric_row(metric_path, metric_cfg): def _tc_to_row(tc):
if not isinstance(metric_cfg, dict):
return None
return { return {
"metric": metric_path, "metric": tc.metric_path,
"operator": metric_cfg.get("operator", ">"), "operator": tc.operator.value,
"warning": metric_cfg.get("warning"), "warning": tc.warning,
"critical": metric_cfg.get("critical"), "critical": tc.critical,
"hysteresis": metric_cfg.get("hysteresis"), "hysteresis": tc.hysteresis,
"count": metric_cfg.get("count", 1), "count": tc.count,
"enabled": metric_cfg.get("enabled", True), "enabled": tc.enabled,
} }
threshold_config_list = [] threshold_config_list = []
raw_tconfigs = config.get("threshold_configs") or {} if threshold_checker is not None:
if raw_tconfigs: if threshold_checker.threshold_configs:
for cfg_name, cfg_data in sorted(raw_tconfigs.items()): for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()):
if not isinstance(cfg_data, dict): # For the default config use the merged effective set;
continue # for named overrides use only the explicitly defined metrics
metrics = [ # (threshold_raw_configs) so inherited defaults are not repeated.
r for r in ( if cfg_name == "default":
_parse_metric_row(mp, mc) display_metrics = cfg_metrics
for mp, mc in (cfg_data.get("thresholds") or {}).items() else:
) if r display_metrics = threshold_checker.threshold_raw_configs.get(cfg_name, cfg_metrics)
] metrics = sorted(
threshold_config_list.append({ [_tc_to_row(tc) for tc in display_metrics.values()],
"name": cfg_name, key=lambda m: m["metric"],
"metrics": sorted(metrics, key=lambda m: m["metric"]), )
}) threshold_config_list.append({"name": cfg_name, "metrics": metrics})
elif config.get("thresholds"): elif threshold_checker.thresholds:
metrics = [ metrics = sorted(
r for r in ( [_tc_to_row(tc) for tc in threshold_checker.thresholds.values()],
_parse_metric_row(mp, mc) key=lambda m: m["metric"],
for mp, mc in config["thresholds"].items() )
) if r threshold_config_list.append({"name": "default", "metrics": metrics})
]
threshold_config_list.append({
"name": "default",
"metrics": sorted(metrics, key=lambda m: m["metric"]),
})
# ---- Hosts summary ---------------------------------------------------- # ---- Hosts summary ----------------------------------------------------
hosts_list = [] hosts_list = []
+11 -3
View File
@@ -4,7 +4,7 @@
<style> <style>
body { html, body {
height: auto; height: auto;
overflow-y: auto; overflow-y: auto;
} }
@@ -175,8 +175,12 @@
.alert-hostname { .alert-hostname {
font-weight: bold; font-weight: bold;
color: #333; color: #0066cc;
font-size: 1.1em; font-size: 1.1em;
text-decoration: none;
}
.alert-hostname:hover {
text-decoration: underline;
} }
.alert-metric { .alert-metric {
@@ -405,6 +409,10 @@
} else if (alert.threshold_value !== undefined && alert.threshold_value !== null && alert.operator) { } else if (alert.threshold_value !== undefined && alert.threshold_value !== null && alert.operator) {
valueText += ` <span class="threshold-info">(threshold: ${alert.operator} ${formatValue(alert.threshold_value)})</span>`; valueText += ` <span class="threshold-info">(threshold: ${alert.operator} ${formatValue(alert.threshold_value)})</span>`;
} }
if (alert.recovery_threshold !== undefined && alert.recovery_threshold !== null) {
const recOp = (alert.operator === '>' || alert.operator === '>=') ? '<' : '>';
valueText += ` <span class="threshold-info" style="color:#888">(recovers ${recOp} ${formatValue(alert.recovery_threshold)})</span>`;
}
// Build actions section // Build actions section
let actionsHtml = ''; let actionsHtml = '';
@@ -429,7 +437,7 @@
<div class="alert-main"> <div class="alert-main">
<div class="alert-header"> <div class="alert-header">
<span class="alert-level ${level}">${alert.level}</span> <span class="alert-level ${level}">${alert.level}</span>
<span class="alert-hostname">${alert.hostname}</span> <a class="alert-hostname" href="/plugins/${alert.hostname}">${alert.hostname}</a>
</div> </div>
<div class="alert-metric">${alert.metric_path}</div> <div class="alert-metric">${alert.metric_path}</div>
<div class="alert-details"> <div class="alert-details">
+72 -6
View File
@@ -152,6 +152,31 @@
} }
.host-action-btn.delete-btn:hover { background: #ffcdd2; } .host-action-btn.delete-btn:hover { background: #ffcdd2; }
/* ── Action result toast ───────────────────────────────────── */
#action-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: #323232;
color: #fff;
padding: 12px 22px;
border-radius: 6px;
font-size: 0.9em;
max-width: 480px;
text-align: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 9000;
white-space: pre-wrap;
}
#action-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
#action-toast.error { background: #c62828; }
/* ── Host body ──────────────────────────────────────────────── */ /* ── Host body ──────────────────────────────────────────────── */
.host-body { .host-body {
@@ -401,12 +426,10 @@
{% endif %} {% endif %}
<span class="os-label" id="os-label-{{ host.name }}"></span> <span class="os-label" id="os-label-{{ host.name }}"></span>
{% if host.is_owner %} {% if host.is_owner %}
<a class="host-action-btn update-btn" <button class="host-action-btn update-btn"
href="/u?h={{ host.name }}" onclick="event.stopPropagation(); hostAction(this, '/u?h={{ host.name }}')">Update</button>
onclick="event.stopPropagation()">Update</a> <button class="host-action-btn delete-btn"
<a class="host-action-btn delete-btn" onclick="event.stopPropagation(); hostDelete(this, '{{ host.name }}')">Delete</button>
href="/d?h={{ host.name }}"
onclick="event.stopPropagation(); return confirm('Delete host {{ host.name }}?')">Delete</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -1204,6 +1227,49 @@
fetchHostGlance(first.dataset.hostname); fetchHostGlance(first.dataset.hostname);
} }
}); });
// ── Host action helpers ──────────────────────────────────────
let _toastTimer = null;
function showToast(msg, isError) {
const t = document.getElementById('action-toast');
t.textContent = msg;
t.classList.toggle('error', !!isError);
t.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => t.classList.remove('show'), 4000);
}
async function hostAction(btn, url) {
btn.disabled = true;
try {
const res = await fetch(url);
const text = await res.text();
showToast(text, !res.ok);
} catch (e) {
showToast('Request failed: ' + e.message, true);
} finally {
btn.disabled = false;
}
}
async function hostDelete(btn, hostname) {
if (!confirm('Delete host ' + hostname + '?')) return;
btn.disabled = true;
try {
const res = await fetch('/d?h=' + encodeURIComponent(hostname));
const text = await res.text();
showToast(text, !res.ok);
if (res.ok) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
if (card) card.remove();
}
} catch (e) {
showToast('Request failed: ' + e.message, true);
btn.disabled = false;
}
}
</script> </script>
<div id="action-toast"></div>
</body> </body>
</html> </html>
+99 -27
View File
@@ -57,6 +57,7 @@ class AlertState:
self.last_notification = None self.last_notification = None
self.threshold_value = None # The threshold value that triggered alert self.threshold_value = None # The threshold value that triggered alert
self.operator = None # The comparison operator (>, <, >=, etc.) self.operator = None # The comparison operator (>, <, >=, etc.)
self.hysteresis: Optional[float] = None # Hysteresis fraction used for recovery
self.formatted_message = None # Formatted display message for UI self.formatted_message = None # Formatted display message for UI
self.acknowledged = False # Whether alert has been acknowledged self.acknowledged = False # Whether alert has been acknowledged
self.acknowledged_at = None # Timestamp when acknowledged self.acknowledged_at = None # Timestamp when acknowledged
@@ -152,6 +153,15 @@ class AlertState:
if self.formatted_message is not None: if self.formatted_message is not None:
result["formatted_message"] = self.formatted_message result["formatted_message"] = self.formatted_message
# Compute and expose the recovery threshold so the UI can display it
if (self.hysteresis and self.threshold_value is not None
and self.operator is not None):
ha = abs(self.threshold_value * self.hysteresis)
if self.operator in ('>', '>='):
result["recovery_threshold"] = round(self.threshold_value - ha, 4)
elif self.operator in ('<', '<='):
result["recovery_threshold"] = round(self.threshold_value + ha, 4)
return result return result
def __setstate__(self, state): def __setstate__(self, state):
@@ -159,6 +169,8 @@ class AlertState:
self.__dict__.update(state) self.__dict__.update(state)
if not hasattr(self, 'consecutive_count'): if not hasattr(self, 'consecutive_count'):
self.consecutive_count = 0 self.consecutive_count = 0
if not hasattr(self, 'hysteresis'):
self.hysteresis = None
def acknowledge(self): def acknowledge(self):
"""Acknowledge this alert to stop reminder notifications.""" """Acknowledge this alert to stop reminder notifications."""
@@ -546,7 +558,7 @@ class ThresholdChecker:
critical = threshold_config.get("critical") critical = threshold_config.get("critical")
operator = threshold_config.get("operator", ">") operator = threshold_config.get("operator", ">")
display = threshold_config.get("display", "(threshold: {op_symbol} {threshold_value})") display = threshold_config.get("display", "(threshold: {op_symbol} {threshold_value})")
hysteresis = threshold_config.get("hysteresis", 0.1) # 10% default hysteresis = threshold_config.get("hysteresis", 0.02) # 2% default
enabled = threshold_config.get("enabled", True) enabled = threshold_config.get("enabled", True)
if warning is None and critical is None: if warning is None and critical is None:
@@ -649,7 +661,7 @@ class ThresholdChecker:
warning = rtt_thresholds.get("warning") warning = rtt_thresholds.get("warning")
critical = rtt_thresholds.get("critical") critical = rtt_thresholds.get("critical")
operator = rtt_thresholds.get("operator", ">") operator = rtt_thresholds.get("operator", ">")
hysteresis = rtt_thresholds.get("hysteresis", 0.1) # 10% default hysteresis = rtt_thresholds.get("hysteresis", 0.02) # 2% default
enabled = rtt_thresholds.get("enabled", True) enabled = rtt_thresholds.get("enabled", True)
display = rtt_thresholds.get("display") display = rtt_thresholds.get("display")
count = rtt_thresholds.get("count", 1) count = rtt_thresholds.get("count", 1)
@@ -794,6 +806,12 @@ class ThresholdChecker:
elif new_level == AlertLevel.WARNING and threshold.warning is not None: elif new_level == AlertLevel.WARNING and threshold.warning is not None:
threshold_value = threshold.warning threshold_value = threshold.warning
# Keep hysteresis on the state so the UI can show the recovery threshold
if new_level != AlertLevel.OK:
alert_state.hysteresis = threshold.hysteresis
else:
alert_state.hysteresis = None
# Update state and check for changes # Update state and check for changes
old_level = alert_state.level old_level = alert_state.level
if alert_state.update(new_level, value, threshold_value, threshold.operator.value): if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
@@ -805,26 +823,33 @@ class ThresholdChecker:
return None return None
def _find_threshold( def _find_threshold(
self, thresholds: Dict[str, "ThresholdConfig"], metric_path: str self, thresholds: Dict[str, "ThresholdConfig"], metric_path: str
) -> Optional["ThresholdConfig"]: ) -> Tuple[Optional["ThresholdConfig"], Optional[str]]:
"""Return the threshold for *metric_path*, falling back to suffix matches. """Return (threshold, check_name) for *metric_path*, falling back to suffix matches.
Allows generic thresholds like ``ping_monitor.rtt_avg`` to match Allows generic thresholds like ``nagios_runner.status_code`` to match
fully-qualified paths like ``ping_monitor.8_8_8_8_rtt_avg``. fully-qualified paths like ``nagios_runner.check_disk_root_status_code``.
The exact match is always tried first; then successive leading The exact match is always tried first; then successive leading
underscore-delimited segments are stripped from the field name until underscore-delimited segments are stripped from the field name until
a match is found or no segments remain. a match is found or no segments remain.
Returns:
(ThresholdConfig, None) for an exact match.
(ThresholdConfig, "check_disk_root") for a suffix match — the second
element is the stripped prefix, available as ``{check_name}`` in
display format templates.
(None, None) when no threshold is found.
""" """
if metric_path in thresholds: if metric_path in thresholds:
return thresholds[metric_path] return thresholds[metric_path], None
plugin, sep, field = metric_path.partition(".") plugin, sep, field = metric_path.partition(".")
if not sep: if not sep:
return None return None, None
parts = field.split("_") parts = field.split("_")
for i in range(1, len(parts)): for i in range(1, len(parts)):
candidate = plugin + "." + "_".join(parts[i:]) candidate = plugin + "." + "_".join(parts[i:])
if candidate in thresholds: if candidate in thresholds:
return thresholds[candidate] return thresholds[candidate], "_".join(parts[:i])
return None return None, None
def check_plugin_data( def check_plugin_data(
self, self,
@@ -854,7 +879,7 @@ class ThresholdChecker:
for metric_name, value in data.items(): for metric_name, value in data.items():
metric_path = f"{plugin_name}.{metric_name}" metric_path = f"{plugin_name}.{metric_name}"
threshold = self._find_threshold(thresholds, metric_path) threshold, check_name = self._find_threshold(thresholds, metric_path)
if threshold is None: if threshold is None:
continue continue
@@ -877,13 +902,15 @@ class ThresholdChecker:
elif new_level == AlertLevel.WARNING and threshold.warning is not None: elif new_level == AlertLevel.WARNING and threshold.warning is not None:
threshold_value = threshold.warning threshold_value = threshold.warning
alert_state.hysteresis = threshold.hysteresis if new_level != AlertLevel.OK else None
# Update state and check for changes # Update state and check for changes
old_level = alert_state.level old_level = alert_state.level
if alert_state.update(new_level, value, threshold_value, threshold.operator.value): if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
state_changes.append((metric_path, old_level, new_level, value)) state_changes.append((metric_path, old_level, new_level, value))
self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, data) self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, data, check_name=check_name, metric_name=metric_name)
elif new_level != AlertLevel.OK: elif new_level != AlertLevel.OK:
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, data) self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, data, check_name=check_name, metric_name=metric_name)
# Check nested metrics (e.g., partition data in disk_monitor) # Check nested metrics (e.g., partition data in disk_monitor)
self._check_nested_metrics( self._check_nested_metrics(
@@ -943,6 +970,8 @@ class ThresholdChecker:
elif new_level == AlertLevel.WARNING and threshold.warning is not None: elif new_level == AlertLevel.WARNING and threshold.warning is not None:
threshold_value = threshold.warning threshold_value = threshold.warning
alert_state.hysteresis = threshold.hysteresis if new_level != AlertLevel.OK else None
old_level = alert_state.level old_level = alert_state.level
if alert_state.update(new_level, value, threshold_value, threshold.operator.value): if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
state_changes.append((metric_path, old_level, new_level, value)) state_changes.append((metric_path, old_level, new_level, value))
@@ -959,6 +988,8 @@ class ThresholdChecker:
value: Any, value: Any,
threshold: ThresholdConfig, threshold: ThresholdConfig,
plugin_data: Optional[Dict[str, Any]] = None, plugin_data: Optional[Dict[str, Any]] = None,
check_name: Optional[str] = None,
metric_name: Optional[str] = None,
): ):
"""Trigger a notification for an alert state change. """Trigger a notification for an alert state change.
@@ -997,7 +1028,9 @@ class ThresholdChecker:
value=display_value, value=display_value,
threshold_value=threshold_value, threshold_value=threshold_value,
op_symbol=op_symbol, op_symbol=op_symbol,
plugin_data=plugin_data plugin_data=plugin_data,
check_name=check_name,
metric_name=metric_name,
) )
message = f"{metric_path} = {display_value} {threshold_info}" message = f"{metric_path} = {display_value} {threshold_info}"
else: else:
@@ -1010,7 +1043,9 @@ class ThresholdChecker:
value=display_value, value=display_value,
threshold_value=threshold_value, threshold_value=threshold_value,
op_symbol=op_symbol, op_symbol=op_symbol,
plugin_data=plugin_data plugin_data=plugin_data,
check_name=check_name,
metric_name=metric_name,
) )
message = f"{metric_path} = {display_value} {threshold_info}" message = f"{metric_path} = {display_value} {threshold_info}"
else: else:
@@ -1027,7 +1062,9 @@ class ThresholdChecker:
value=display_value, value=display_value,
threshold_value=threshold_value, threshold_value=threshold_value,
op_symbol=op_symbol, op_symbol=op_symbol,
plugin_data=plugin_data plugin_data=plugin_data,
check_name=check_name,
metric_name=metric_name,
) )
return lvl, message, formatted_threshold_msg return lvl, message, formatted_threshold_msg
@@ -1080,15 +1117,21 @@ class ThresholdChecker:
threshold_value: float, threshold_value: float,
op_symbol: str, op_symbol: str,
plugin_data: Optional[Dict[str, Any]] = None, plugin_data: Optional[Dict[str, Any]] = None,
check_name: Optional[str] = None,
metric_name: Optional[str] = None,
) -> str: ) -> str:
"""Format the display string using available data. """Format the display string using available data.
Args: Available template variables:
display_format: Format string from threshold config {value} - current metric value
value: Current metric value {threshold_value} - threshold that was exceeded
threshold_value: Threshold value that was exceeded {op_symbol} - comparison operator (>, <, >=, <=, ==, !=)
op_symbol: Comparison operator symbol {check_name} - prefix stripped for generic threshold match
plugin_data: Optional dictionary of plugin data fields (e.g. "check_disk_root" when metric
"check_disk_root_status_code" matched generic
threshold "status_code")
{metric_name} - field name within the plugin data dict
Any key from plugin_data is also available.
Returns: Returns:
Formatted display string Formatted display string
@@ -1100,10 +1143,29 @@ class ThresholdChecker:
'op_symbol': op_symbol, 'op_symbol': op_symbol,
} }
# Add generic-match context variables when available
if check_name is not None:
format_context['check_name'] = check_name
if metric_name is not None:
format_context['metric_name'] = metric_name
# Add all plugin data fields if available # Add all plugin data fields if available
if plugin_data: if plugin_data:
format_context.update(plugin_data) format_context.update(plugin_data)
# For nagios_runner generic matches, expose the matched check's output
# and status as short aliases {output} and {status} so display templates
# don't need to use the full {check_disk_root_output} form.
if check_name and plugin_data:
if 'output' not in format_context:
output = plugin_data.get(f"{check_name}_output")
if output is not None:
format_context['output'] = output
if 'status' not in format_context:
status = plugin_data.get(f"{check_name}_status")
if status is not None:
format_context['status'] = status
try: try:
# Format the display string # Format the display string
return display_format.format(**format_context) return display_format.format(**format_context)
@@ -1133,6 +1195,8 @@ class ThresholdChecker:
value: Any, value: Any,
threshold: ThresholdConfig, threshold: ThresholdConfig,
plugin_data: Optional[Dict[str, Any]], plugin_data: Optional[Dict[str, Any]],
check_name: Optional[str] = None,
metric_name: Optional[str] = None,
) -> None: ) -> None:
"""Handle a state-change transition with grace-period logic. """Handle a state-change transition with grace-period logic.
@@ -1145,7 +1209,8 @@ class ThresholdChecker:
- Past grace: fires the RECOVER notification normally. - Past grace: fires the RECOVER notification normally.
""" """
lvl, message, formatted_msg = self._trigger_notification( lvl, message, formatted_msg = self._trigger_notification(
host_name, metric_path, old_level, new_level, value, threshold, plugin_data host_name, metric_path, old_level, new_level, value, threshold, plugin_data,
check_name=check_name, metric_name=metric_name,
) )
alert_state.formatted_message = formatted_msg alert_state.formatted_message = formatted_msg
@@ -1181,6 +1246,8 @@ class ThresholdChecker:
value: Any, value: Any,
threshold: ThresholdConfig, threshold: ThresholdConfig,
plugin_data: Optional[Dict[str, Any]], plugin_data: Optional[Dict[str, Any]],
check_name: Optional[str] = None,
metric_name: Optional[str] = None,
) -> None: ) -> None:
"""Called when alert level is unchanged and non-OK. """Called when alert level is unchanged and non-OK.
@@ -1190,7 +1257,8 @@ class ThresholdChecker:
if alert_state.pending_since is not None: if alert_state.pending_since is not None:
if time.time() - alert_state.pending_since >= self.grace_seconds: if time.time() - alert_state.pending_since >= self.grace_seconds:
lvl, message, formatted_msg = self._trigger_notification( lvl, message, formatted_msg = self._trigger_notification(
host_name, metric_path, AlertLevel.OK, alert_state.level, value, threshold, plugin_data host_name, metric_path, AlertLevel.OK, alert_state.level, value, threshold, plugin_data,
check_name=check_name, metric_name=metric_name,
) )
alert_state.formatted_message = formatted_msg alert_state.formatted_message = formatted_msg
self._send_notification( self._send_notification(
@@ -1199,7 +1267,7 @@ class ThresholdChecker:
alert_state.pending_since = None alert_state.pending_since = None
# else: still within grace window, do nothing # else: still within grace window, do nothing
else: else:
self._check_renotify(host_name, alert_state, metric_path, value, threshold, plugin_data) self._check_renotify(host_name, alert_state, metric_path, value, threshold, plugin_data, check_name=check_name, metric_name=metric_name)
def _check_renotify( def _check_renotify(
self, self,
@@ -1209,6 +1277,8 @@ class ThresholdChecker:
value: Any, value: Any,
threshold: ThresholdConfig, threshold: ThresholdConfig,
plugin_data: Optional[Dict[str, Any]] = None, plugin_data: Optional[Dict[str, Any]] = None,
check_name: Optional[str] = None,
metric_name: Optional[str] = None,
): ):
"""Check if we should send a repeat notification. """Check if we should send a repeat notification.
@@ -1255,7 +1325,9 @@ class ThresholdChecker:
value=value, value=value,
threshold_value=threshold_value, threshold_value=threshold_value,
op_symbol=op_symbol, op_symbol=op_symbol,
plugin_data=plugin_data plugin_data=plugin_data,
check_name=check_name,
metric_name=metric_name,
) )
message = f"REMINDER ({alert_state.level.name}): {host_name} - {metric_path} = {value} {threshold_info}, ongoing for {int(now - alert_state.since)}s" message = f"REMINDER ({alert_state.level.name}): {host_name} - {metric_path} = {value} {threshold_info}, ongoing for {int(now - alert_state.since)}s"
else: else:
@@ -1288,7 +1360,7 @@ class ThresholdChecker:
if not host.alert_states: if not host.alert_states:
continue continue
configured = self.get_thresholds_for_host(hostname) configured = self.get_thresholds_for_host(hostname)
stale = [mp for mp in host.alert_states if mp not in configured] stale = [mp for mp in host.alert_states if self._find_threshold(configured, mp)[0] is None]
for mp in stale: for mp in stale:
logger.info( logger.info(
"Purging stale alert state for %s / %s (no threshold configured)", "Purging stale alert state for %s / %s (no threshold configured)",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hbd" name = "hbd"
version = "5.1.17" version = "5.1.21"
description = "Heartbeat monitoring system — client (hbc) and server (hbd)" description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+27 -5
View File
@@ -41,7 +41,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
# updated by scripts/bumpminor.sh # updated by scripts/bumpminor.sh
__version__ = "5.1.17" __version__ = "5.1.21"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Protocol (mirrors hbd/common/proto.py) # Protocol (mirrors hbd/common/proto.py)
@@ -487,6 +487,12 @@ class CPUMonitorPlugin(MonitorPlugin):
except Exception: except Exception:
pass pass
try:
with open("/proc/uptime") as fh:
data["uptime_seconds"] = int(float(fh.read().split()[0]))
except Exception:
pass
return data return data
@@ -535,6 +541,20 @@ class MemoryMonitorPlugin(MonitorPlugin):
total = mi.get("MemTotal", 0) total = mi.get("MemTotal", 0)
avail = mi.get("MemAvailable", mi.get("MemFree", 0)) avail = mi.get("MemAvailable", mi.get("MemFree", 0))
free = mi.get("MemFree", 0) free = mi.get("MemFree", 0)
# ZFS ARC is reclaimable but not included in MemAvailable; add it.
arc_kb = 0
try:
with open("/proc/spl/kstat/zfs/arcstats") as _f:
for _line in _f:
_p = _line.split()
if len(_p) >= 3 and _p[0] == "size":
arc_kb = int(_p[2]) // 1024
break
except (OSError, ValueError):
pass
avail = min(avail + arc_kb, total)
used = total - avail used = total - avail
data: Dict[str, Any] = { data: Dict[str, Any] = {
"memory_total": total * 1024, "memory_total": total * 1024,
@@ -1052,8 +1072,8 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
if args.message: if args.message:
bmsg["service"] = "service" bmsg["service"] = "service"
bmsg["msg"] = args.message bmsg["msg"] = args.message
for c in connections: target = next((c for c in connections if c._transport), connections[0])
await c.sendto(bmsg) await target.sendto(bmsg)
if args.message and not args.daemon: if args.message and not args.daemon:
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
for c in connections: for c in connections:
@@ -1085,11 +1105,13 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
pass pass
log.info("shutting down") log.info("shutting down")
for conn in connections: target = next((c for c in connections if c._transport), connections[0] if connections else None)
if target:
try: try:
await conn.sendto({"shutdown": 1, "acks": conn.ackcount}) await target.sendto({"shutdown": 1, "acks": target.ackcount})
except Exception: except Exception:
pass pass
for conn in connections:
conn.close() conn.close()
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
for plugin in plugins: for plugin in plugins: