status alerts grace period 10 mins
This commit is contained in:
@@ -825,7 +825,7 @@ full-size PNG when a graph is clicked.
|
|||||||
|
|
||||||
The collector also sends an ntfy notification when:
|
The collector also sends an ntfy notification when:
|
||||||
|
|
||||||
- The UPS status changes
|
- The UPS status changes and the new status persists for 10 minutes
|
||||||
- Mean battery charge over the last 10 measurements crosses below 50%
|
- Mean battery charge over the last 10 measurements crosses below 50%
|
||||||
- Mean battery charge over the last 10 measurements crosses below 5%
|
- Mean battery charge over the last 10 measurements crosses below 5%
|
||||||
- Estimated battery runtime crosses below 5 minutes
|
- Estimated battery runtime crosses below 5 minutes
|
||||||
@@ -835,6 +835,12 @@ Alert state is stored in `/var/lib/rrd/bx950mi-alert-state.json`, allowing
|
|||||||
changes to be detected across cron runs. The first run establishes a baseline
|
changes to be detected across cron runs. The first run establishes a baseline
|
||||||
without sending notifications. Threshold notifications can fire again after a
|
without sending notifications. Threshold notifications can fire again after a
|
||||||
value recovers above the threshold and later drops below it.
|
value recovers above the threshold and later drops below it.
|
||||||
|
Status alerts wait until the new status has persisted for 10 minutes. Returning
|
||||||
|
to the last confirmed status cancels the pending alert, so brief flip-flops
|
||||||
|
produce no status notifications. A different pending status restarts the timer.
|
||||||
|
Pending changes are saved across cron runs; alerts are sent on the first run
|
||||||
|
at or after the 10-minute mark. This delay applies only to status notifications:
|
||||||
|
battery/runtime alerts and automatic shutdowns still use the current readings.
|
||||||
Battery-charge alerts start after 10 valid measurements have been collected;
|
Battery-charge alerts start after 10 valid measurements have been collected;
|
||||||
using their rolling mean prevents a single erroneous `0%` report from causing
|
using their rolling mean prevents a single erroneous `0%` report from causing
|
||||||
a false alarm. Raw measurements are still stored in the RRD and graphed.
|
a false alarm. Raw measurements are still stored in the RRD and graphed.
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Status alert regression tests with hardware integrations stubbed out."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
with patch.dict(sys.modules, {
|
||||||
|
name: MagicMock()
|
||||||
|
for name in ("paramiko", "rrdtool", "dbus_next", "dbus_next.aio",
|
||||||
|
"dbus_next.constants", "PyNUTClient")
|
||||||
|
}):
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"collector_under_test", Path(__file__).with_name("ups2rrd.py")
|
||||||
|
)
|
||||||
|
collector = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(collector)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusAlertTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.state_file = str(Path(self.directory.name) / "state.json")
|
||||||
|
for target, value in (
|
||||||
|
("ALERT_STATE_FILE", self.state_file),
|
||||||
|
("notify", MagicMock()),
|
||||||
|
("shutdown_remote_server", MagicMock()),
|
||||||
|
("shutdown_local_server", MagicMock()),
|
||||||
|
):
|
||||||
|
patcher = patch.object(collector, target, value)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def sample(self, seconds, status, runtime=1000):
|
||||||
|
with patch.object(collector.time, "time", return_value=seconds):
|
||||||
|
collector.process_alerts({
|
||||||
|
"status": status, "battery_charge": 100,
|
||||||
|
"battery_runtime": runtime,
|
||||||
|
})
|
||||||
|
with open(self.state_file) as state_file:
|
||||||
|
return json.load(state_file)
|
||||||
|
|
||||||
|
def test_initial_baseline_and_short_flip_flops(self):
|
||||||
|
self.sample(0, "OL")
|
||||||
|
self.sample(60, "OB")
|
||||||
|
self.sample(659, "OL")
|
||||||
|
self.sample(700, "OB")
|
||||||
|
state = self.sample(1300, "OL")
|
||||||
|
collector.notify.assert_not_called()
|
||||||
|
self.assertIsNone(state["pending_status"])
|
||||||
|
|
||||||
|
def test_persistent_change_and_recovery_each_alert_once(self):
|
||||||
|
self.sample(0, "OL")
|
||||||
|
self.sample(60, "OB")
|
||||||
|
self.sample(659, "OB")
|
||||||
|
collector.notify.assert_not_called()
|
||||||
|
self.sample(660, "OB")
|
||||||
|
self.sample(720, "OB")
|
||||||
|
collector.notify.assert_called_once_with("UPS status changed: OL -> OB")
|
||||||
|
self.sample(780, "OL")
|
||||||
|
self.sample(1380, "OL")
|
||||||
|
self.assertEqual(collector.notify.call_count, 2)
|
||||||
|
collector.notify.assert_called_with("UPS status changed: OB -> OL")
|
||||||
|
|
||||||
|
def test_different_pending_status_restarts_timer(self):
|
||||||
|
self.sample(0, "OL")
|
||||||
|
self.sample(60, "OB")
|
||||||
|
self.sample(600, "LB")
|
||||||
|
self.sample(660, "LB")
|
||||||
|
collector.notify.assert_not_called()
|
||||||
|
self.sample(1200, "LB")
|
||||||
|
collector.notify.assert_called_once_with("UPS status changed: OL -> LB")
|
||||||
|
|
||||||
|
def test_existing_state_migrates_and_alerts_after_late_poll(self):
|
||||||
|
with open(self.state_file, "w") as state_file:
|
||||||
|
json.dump({"status": "OL"}, state_file)
|
||||||
|
self.sample(100, "OB")
|
||||||
|
collector.notify.assert_not_called()
|
||||||
|
self.sample(750, "OB")
|
||||||
|
collector.notify.assert_called_once_with("UPS status changed: OL -> OB")
|
||||||
|
|
||||||
|
def test_runtime_alert_and_shutdowns_are_immediate(self):
|
||||||
|
self.sample(0, "OL")
|
||||||
|
state = self.sample(60, "OB", runtime=100)
|
||||||
|
self.assertEqual(state["confirmed_status"], "OL")
|
||||||
|
collector.shutdown_remote_server.assert_called_once()
|
||||||
|
collector.shutdown_local_server.assert_called_once()
|
||||||
|
messages = [call.args[0] for call in collector.notify.call_args_list]
|
||||||
|
self.assertTrue(any("runtime is below 5 minutes" in m for m in messages))
|
||||||
|
self.assertFalse(any("UPS status changed" in m for m in messages))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+33
-9
@@ -71,6 +71,7 @@ SHUTDOWN_COMMAND = "sudo /usr/sbin/shutdown -h now"
|
|||||||
REMOTE_SHUTDOWN_RUNTIME_SECONDS = 5 * 60
|
REMOTE_SHUTDOWN_RUNTIME_SECONDS = 5 * 60
|
||||||
LOCAL_SHUTDOWN_RUNTIME_SECONDS = 200
|
LOCAL_SHUTDOWN_RUNTIME_SECONDS = 200
|
||||||
BATTERY_CHARGE_MEAN_SAMPLES = 10
|
BATTERY_CHARGE_MEAN_SAMPLES = 10
|
||||||
|
STATUS_ALERT_DELAY_SECONDS = 10 * 60
|
||||||
|
|
||||||
GRAPH_COLOR_ARGS = [
|
GRAPH_COLOR_ARGS = [
|
||||||
"--color", "BACK#F7F5EFFF",
|
"--color", "BACK#F7F5EFFF",
|
||||||
@@ -486,6 +487,36 @@ def crossed_below(previous, current, threshold):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_status_alert(previous, current_status):
|
||||||
|
"""Notify only after a new status persists for ten minutes across runs."""
|
||||||
|
|
||||||
|
# Older state files only have the latest observed status.
|
||||||
|
confirmed = previous.get("confirmed_status", previous.get("status"))
|
||||||
|
pending = previous.get("pending_status")
|
||||||
|
pending_since = previous.get("pending_status_since")
|
||||||
|
|
||||||
|
if not confirmed:
|
||||||
|
confirmed = current_status
|
||||||
|
pending = pending_since = None
|
||||||
|
elif current_status == confirmed:
|
||||||
|
pending = pending_since = None
|
||||||
|
elif current_status:
|
||||||
|
now = time.time()
|
||||||
|
if current_status != pending or pending_since is None:
|
||||||
|
pending = current_status
|
||||||
|
pending_since = now
|
||||||
|
elif now - pending_since >= STATUS_ALERT_DELAY_SECONDS:
|
||||||
|
notify(f"UPS status changed: {confirmed} -> {current_status}")
|
||||||
|
confirmed = current_status
|
||||||
|
pending = pending_since = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"confirmed_status": confirmed,
|
||||||
|
"pending_status": pending,
|
||||||
|
"pending_status_since": pending_since,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def battery_charge_history(previous, current_charge):
|
def battery_charge_history(previous, current_charge):
|
||||||
"""Return up to the last 10 valid battery-charge measurements."""
|
"""Return up to the last 10 valid battery-charge measurements."""
|
||||||
|
|
||||||
@@ -585,18 +616,10 @@ def process_alerts(current):
|
|||||||
current_runtime = current.get("battery_runtime")
|
current_runtime = current.get("battery_runtime")
|
||||||
shutdown_sent = previous.get("remote_shutdown_sent", False)
|
shutdown_sent = previous.get("remote_shutdown_sent", False)
|
||||||
local_shutdown_sent = previous.get("local_shutdown_sent", False)
|
local_shutdown_sent = previous.get("local_shutdown_sent", False)
|
||||||
|
status_alert_state = process_status_alert(previous, current_status)
|
||||||
|
|
||||||
# An empty state is the initial baseline, not an alert condition.
|
# An empty state is the initial baseline, not an alert condition.
|
||||||
if previous:
|
if previous:
|
||||||
if (
|
|
||||||
previous_status
|
|
||||||
and current_status
|
|
||||||
and current_status != previous_status
|
|
||||||
):
|
|
||||||
notify(
|
|
||||||
f"UPS status changed: {previous_status} -> {current_status}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if current_charge_mean is not None and (
|
if current_charge_mean is not None and (
|
||||||
previous_charge_mean is None
|
previous_charge_mean is None
|
||||||
or crossed_below(previous_charge_mean, current_charge_mean, 50)
|
or crossed_below(previous_charge_mean, current_charge_mean, 50)
|
||||||
@@ -673,6 +696,7 @@ def process_alerts(current):
|
|||||||
|
|
||||||
# Keep the last valid reading when NUT temporarily omits a value.
|
# Keep the last valid reading when NUT temporarily omits a value.
|
||||||
state = {
|
state = {
|
||||||
|
**status_alert_state,
|
||||||
"status": current_status or previous_status,
|
"status": current_status or previous_status,
|
||||||
"battery_charge": (
|
"battery_charge": (
|
||||||
current_charge if current_charge is not None else previous_charge
|
current_charge if current_charge is not None else previous_charge
|
||||||
|
|||||||
Reference in New Issue
Block a user