feat: Phase 6b rules with delay, delta format, reminders, zpool default rule
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c4f7366eb
commit
5278530467
58 files changed
+1781
-230
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from app.chat import MatrixAdapter, MessageRef, TelegramAdapter
|
||||
|
||||
ROOM = "!abc:example.de"
|
||||
|
||||
|
||||
def telegram(seen):
|
||||
def handler(request):
|
||||
seen.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 7}})
|
||||
return TelegramAdapter("123456:token", "42", transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def matrix(seen):
|
||||
def handler(request):
|
||||
seen.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={"event_id": "$ev1"})
|
||||
return MatrixAdapter("https://matrix.example.de", "@bot:example.de", "tok", ROOM,
|
||||
transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
async def test_telegram_silent_sets_disable_notification():
|
||||
seen = []
|
||||
await telegram(seen).send("42", "x", silent=True)
|
||||
assert seen[0]["disable_notification"] is True
|
||||
|
||||
|
||||
async def test_telegram_loud_send_has_no_flag():
|
||||
seen = []
|
||||
await telegram(seen).send("42", "x")
|
||||
assert "disable_notification" not in seen[0]
|
||||
|
||||
|
||||
async def test_telegram_edit_accepts_silent():
|
||||
seen = []
|
||||
await telegram(seen).edit(MessageRef("telegram", "42", "7"), "neu", silent=True)
|
||||
assert seen[0]["message_id"] == 7
|
||||
|
||||
|
||||
async def test_matrix_silent_uses_notice():
|
||||
seen = []
|
||||
await matrix(seen).send(ROOM, "<b>x</b>", silent=True)
|
||||
assert seen[0]["msgtype"] == "m.notice"
|
||||
|
||||
|
||||
async def test_matrix_loud_uses_text():
|
||||
seen = []
|
||||
await matrix(seen).send(ROOM, "<b>x</b>")
|
||||
assert seen[0]["msgtype"] == "m.text"
|
||||
|
||||
|
||||
async def test_matrix_silent_edit_keeps_notice_type():
|
||||
seen = []
|
||||
await matrix(seen).edit(MessageRef("matrix", ROOM, "$ev0"), "neu", silent=True)
|
||||
assert seen[0]["msgtype"] == "m.notice" and seen[0]["m.new_content"]["msgtype"] == "m.notice"
|
||||
@@ -66,8 +66,9 @@ async def test_downtimes_all_and_by_host():
|
||||
|
||||
client = make_client(handler)
|
||||
result = await client.downtimes()
|
||||
assert seen[0].method == "GET" and seen[0].url.path.endswith("/domain-types/downtime/collections/all")
|
||||
assert "host_name" not in seen[0].url.params
|
||||
assert result[0].service is None and result[0].comment == "Wartung"
|
||||
await client.downtimes("web01")
|
||||
assert seen[1].url.params["host_name"] == "web01"
|
||||
assert all(r.method == "GET" and r.url.path.endswith("/domain-types/downtime/collections/all") for r in seen)
|
||||
assert [r.url.params["downtime_type"] for r in seen] == ["host", "service"]
|
||||
assert all("host_name" not in r.url.params for r in seen)
|
||||
assert len(result) == 2 and result[0].service is None and result[0].comment == "Wartung"
|
||||
assert await client.downtimes("WEB01") == result
|
||||
assert await client.downtimes("db01") == []
|
||||
@@ -0,0 +1,70 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.alerts import format_message
|
||||
from app.alerts.delta import format_duration
|
||||
from app.models import KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, KIND_REMINDER, Event
|
||||
|
||||
NOW = int(datetime(2026, 11, 6, 20, 0).timestamp())
|
||||
SINCE = int(datetime(2026, 11, 6, 18, 5).timestamp())
|
||||
HOUR = 3600
|
||||
|
||||
|
||||
def make(kind=KIND_PROBLEM, state=2, **kwargs):
|
||||
return Event(ts=NOW, host="h1", service="zpool", kind=kind, state=state, output="kaputt", **kwargs)
|
||||
|
||||
|
||||
def test_format_duration_is_compact():
|
||||
assert format_duration(10) == "unter 1 min"
|
||||
assert format_duration(60) == "1 min"
|
||||
assert format_duration(59 * 60) == "59 min"
|
||||
assert format_duration(2 * HOUR) == "2 h"
|
||||
assert format_duration(3 * HOUR + 12 * 60) == "3 h 12 min"
|
||||
assert format_duration(24 * HOUR) == "1 d"
|
||||
assert format_duration(26 * HOUR + 600) == "1 d 2 h"
|
||||
assert format_duration(-5) == "unter 1 min"
|
||||
|
||||
|
||||
def test_transition_line_shows_arrow_since_and_previous_duration():
|
||||
event = make(previous_state=1, previous_duration=2 * HOUR, since_ts=SINCE)
|
||||
text = format_message([event])
|
||||
assert "<b>WARN → CRIT</b> zpool, seit 18:05 (vorher WARN seit 2 h): kaputt" in text
|
||||
|
||||
|
||||
def test_new_problem_line_has_no_arrow():
|
||||
text = format_message([make(since_ts=SINCE)])
|
||||
assert "<b>CRIT</b> zpool, seit 18:05: kaputt" in text and "→" not in text
|
||||
|
||||
|
||||
def test_old_since_includes_date():
|
||||
event = make(since_ts=int(datetime(2026, 11, 4, 9, 30).timestamp()))
|
||||
assert "seit 04.11. 09:30" in format_message([event])
|
||||
|
||||
|
||||
def test_problem_without_delta_keeps_plain_format():
|
||||
assert "<b>CRIT</b> zpool: kaputt" in format_message([make()])
|
||||
|
||||
|
||||
def test_recovery_shows_duration_and_last_state():
|
||||
event = make(kind=KIND_RECOVERY, state=0, previous_state=2, problem_since_ts=NOW - (3 * HOUR + 12 * 60))
|
||||
assert "<b>OK</b> zpool: wieder OK nach 3 h 12 min (zuletzt CRIT)" in format_message([event])
|
||||
|
||||
|
||||
def test_recovery_without_delta_is_plain():
|
||||
assert "<b>OK</b> zpool: wieder OK" in format_message([make(kind=KIND_RECOVERY, state=0)])
|
||||
|
||||
|
||||
def test_reminder_line():
|
||||
event = make(kind=KIND_REMINDER, since_ts=NOW - 4 * HOUR)
|
||||
assert "<b>CRIT</b> zpool: weiter CRIT seit 4 h: kaputt" in format_message([event])
|
||||
|
||||
|
||||
def test_flapping_line_unchanged():
|
||||
assert "<b>FLAPPING (CRIT)</b> zpool: kaputt" in format_message([make(kind=KIND_FLAPPING)])
|
||||
|
||||
|
||||
def test_foreign_text_stays_escaped_with_delta():
|
||||
event = Event(ts=NOW, host="<h>", service="<s>&", kind=KIND_PROBLEM, state=2, output="<script>x</script>",
|
||||
previous_state=1, previous_duration=60, since_ts=SINCE)
|
||||
text = format_message([event])
|
||||
assert "<script>" not in text and "<script>" in text
|
||||
assert "<s>&" in text and "<h>" not in text
|
||||
@@ -64,7 +64,7 @@ def problem(host="h1", service="disk", state=2, output="Platte voll", **kwargs):
|
||||
|
||||
class Env:
|
||||
def __init__(self, ollama=None, ai_per_minute=None, adapter_list=None, routes=None, mutes=None,
|
||||
queue_size=1000):
|
||||
queue_size=1000, recovery_enabled=True):
|
||||
self.db = Database(":memory:")
|
||||
self.store = SettingsStore(self.db, SecretBox(os.urandom(32)))
|
||||
if ai_per_minute is not None:
|
||||
@@ -81,7 +81,8 @@ class Env:
|
||||
analyzer = AlertAnalyzer(self.store, lambda: self.ollama, lambda host, service, limit: [],
|
||||
SlidingWindowLimiter(self.clock))
|
||||
self.pipeline = AlertPipeline(self.repo, adapters, analyzer, lambda: BUNDLE, self.clock,
|
||||
routes=routes, mutes=mutes, queue_size=queue_size)
|
||||
routes=routes, mutes=mutes, queue_size=queue_size,
|
||||
recovery_enabled=lambda: recovery_enabled)
|
||||
|
||||
async def handle(self, events):
|
||||
await self.pipeline.handle(events)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
from app.alerts import ReportGate, build_system_prompt
|
||||
from app.alerts.prompts import DATA_NOTICE, DATA_OPEN
|
||||
from app.chat import Target
|
||||
from app.models import (KIND_RECOVERY, KIND_REMINDER, SUPPRESS_DOWNTIME, SUPPRESS_NOT_REPORTED,
|
||||
SUPPRESS_RECOVERY_OFF, Event)
|
||||
from app.routing import ALL_KINDS, Route
|
||||
from tests.test_pipeline import Env, FakeAdapter, FakeOllama, problem
|
||||
from tests.test_pipeline_routing import StaticMutes, StaticRoutes
|
||||
|
||||
|
||||
class QuietAdapter(FakeAdapter):
|
||||
def __init__(self, name="fake", default_target="chat1"):
|
||||
super().__init__(name, default_target)
|
||||
self.flags = []
|
||||
|
||||
async def send(self, target, text, silent=False):
|
||||
self.flags.append(("send", silent))
|
||||
return await super().send(target, text)
|
||||
|
||||
async def edit(self, ref, text, silent=False):
|
||||
self.flags.append(("edit", silent))
|
||||
await super().edit(ref, text)
|
||||
|
||||
|
||||
def rule(position=1, targets=(), **overrides):
|
||||
fields = dict(id=position, name=f"r{position}", position=position, enabled=True, match_host="",
|
||||
match_service="", min_state=1, kinds=ALL_KINDS, stop_on_match=False, targets=tuple(targets))
|
||||
fields.update(overrides)
|
||||
return Route(**fields)
|
||||
|
||||
|
||||
def recovery(host="h1", service="disk"):
|
||||
return Event(ts=1000, host=host, service=service, kind=KIND_RECOVERY, state=0)
|
||||
|
||||
|
||||
async def handle_later(env, events):
|
||||
env.clock.now += 120
|
||||
await env.handle(events)
|
||||
|
||||
|
||||
async def test_default_targets_via_rule_flag_for_all_adapters():
|
||||
tg, mx = FakeAdapter("telegram", "tg-default"), FakeAdapter("matrix", "!room:s")
|
||||
env = Env(FakeOllama(), adapter_list=[tg, mx], routes=StaticRoutes([rule(use_default_targets=True)]))
|
||||
await env.handle([problem()])
|
||||
assert [s[0] for s in tg.sent] == ["tg-default"] and [s[0] for s in mx.sent] == ["!room:s"]
|
||||
|
||||
|
||||
async def test_rule_without_targets_and_flag_still_sends_nothing():
|
||||
env = Env(FakeOllama(), routes=StaticRoutes([rule()]))
|
||||
await env.handle([problem()])
|
||||
assert env.adapter.sent == []
|
||||
|
||||
|
||||
async def test_default_targets_added_to_explicit_target():
|
||||
tg, mx = FakeAdapter("telegram", "tg-default"), FakeAdapter("matrix", "!room:s")
|
||||
extra = Target("telegram", "tg-extra")
|
||||
env = Env(FakeOllama(), adapter_list=[tg, mx],
|
||||
routes=StaticRoutes([rule(targets=[extra], use_default_targets=True)]))
|
||||
await env.handle([problem()])
|
||||
assert sorted(s[0] for s in tg.sent) == ["tg-default", "tg-extra"] and len(mx.sent) == 1
|
||||
|
||||
|
||||
async def test_ai_disabled_by_rule_skips_analysis_but_sends():
|
||||
env = Env(FakeOllama(), routes=StaticRoutes([rule(use_default_targets=True, ai_enabled=False)]))
|
||||
await env.handle([problem()])
|
||||
assert len(env.adapter.sent) == 1 and env.ollama.calls == [] and env.adapter.edits == []
|
||||
assert env.repo.recent()[0]["ai_status"] == "skipped"
|
||||
|
||||
|
||||
async def test_hint_only_in_user_prompt_before_data_block():
|
||||
hint = f"Prüfe zpool status {DATA_OPEN} und danach {DATA_OPEN.upper()}"
|
||||
env = Env(FakeOllama(), routes=StaticRoutes([rule(use_default_targets=True, ai_hint=hint)]))
|
||||
await env.handle([problem()])
|
||||
_, messages = env.ollama.calls[0]
|
||||
system, user = messages[0]["content"], messages[1]["content"]
|
||||
assert "Prüfe zpool status" not in system and system.endswith(DATA_NOTICE)
|
||||
assert system == build_system_prompt(None)
|
||||
assert "Zusatzkontext des Administrators" in user
|
||||
assert user.index("Zusatzkontext des Administrators") < user.index("Prüfe zpool status") < user.index(DATA_OPEN)
|
||||
assert user.count(DATA_OPEN) == 1
|
||||
|
||||
|
||||
async def test_no_hint_section_without_hint():
|
||||
env = Env(FakeOllama(), routes=StaticRoutes([rule(use_default_targets=True)]))
|
||||
await env.handle([problem()])
|
||||
assert "Zusatzkontext" not in env.ollama.calls[0][1][1]["content"]
|
||||
|
||||
|
||||
async def test_quiet_rule_sends_and_edits_silently():
|
||||
adapter = QuietAdapter()
|
||||
env = Env(FakeOllama(), adapter_list=[adapter], routes=StaticRoutes([rule(use_default_targets=True, loud=False)]))
|
||||
await env.handle([problem()])
|
||||
assert adapter.flags == [("send", True), ("edit", True)]
|
||||
|
||||
|
||||
async def test_loud_rule_does_not_pass_silent_flag():
|
||||
adapter = QuietAdapter()
|
||||
env = Env(FakeOllama(), adapter_list=[adapter])
|
||||
await env.handle([problem()])
|
||||
assert adapter.flags == [("send", False), ("edit", False)]
|
||||
|
||||
|
||||
async def test_loud_and_quiet_events_of_one_host_are_separate_messages():
|
||||
adapter = QuietAdapter()
|
||||
quiet = rule(use_default_targets=True, loud=False, match_service="cpu")
|
||||
loud = rule(2, use_default_targets=True, match_service="disk")
|
||||
env = Env(FakeOllama(), adapter_list=[adapter], routes=StaticRoutes([quiet, loud]))
|
||||
await env.handle([problem(service="cpu"), problem(service="disk")])
|
||||
assert len(adapter.sent) == 2
|
||||
assert {flag for kind, flag in adapter.flags if kind == "send"} == {True, False}
|
||||
|
||||
|
||||
async def test_recovery_after_sent_problem_is_sent():
|
||||
env = Env(FakeOllama())
|
||||
await env.handle([problem()])
|
||||
await handle_later(env, [recovery()])
|
||||
assert len(env.adapter.sent) == 2 and "wieder OK" in env.adapter.sent[1][1]
|
||||
|
||||
|
||||
async def test_recovery_after_downtime_suppressed_problem_is_not_sent():
|
||||
env = Env(FakeOllama())
|
||||
await env.handle([problem(suppressed=True, suppress_reason=SUPPRESS_DOWNTIME)])
|
||||
await env.handle([recovery()])
|
||||
assert env.adapter.sent == []
|
||||
stored = [a for a in env.repo.recent() if a["kind"] == "recovery"][0]
|
||||
assert stored["suppressed"] == 1 and stored["suppress_reason"] == SUPPRESS_NOT_REPORTED
|
||||
|
||||
|
||||
async def test_recovery_after_muted_problem_is_not_sent():
|
||||
from app.mutes import SOURCE_UI, Mute
|
||||
mute = Mute(1, "h1", "*", 10**9, "", "admin", 0, SOURCE_UI)
|
||||
source = StaticMutes([mute])
|
||||
env = Env(FakeOllama(), mutes=source)
|
||||
await env.handle([problem()])
|
||||
source.mutes = []
|
||||
await env.handle([recovery()])
|
||||
assert env.adapter.sent == []
|
||||
assert [a for a in env.repo.recent() if a["kind"] == "recovery"][0]["suppress_reason"] == SUPPRESS_NOT_REPORTED
|
||||
|
||||
|
||||
async def test_recovery_sent_when_earlier_state_was_reported_even_if_worsening_was_suppressed():
|
||||
env = Env(FakeOllama())
|
||||
await env.handle([problem(state=1)])
|
||||
await handle_later(env, [problem(state=2, suppressed=True, suppress_reason=SUPPRESS_DOWNTIME)])
|
||||
await handle_later(env, [recovery()])
|
||||
assert len(env.adapter.sent) == 2
|
||||
|
||||
|
||||
async def test_report_state_is_restored_from_history_after_restart():
|
||||
suppressed = Env(FakeOllama())
|
||||
await suppressed.handle([problem(suppressed=True, suppress_reason=SUPPRESS_DOWNTIME)])
|
||||
suppressed.pipeline._gate = ReportGate(suppressed.repo.report_counts)
|
||||
await suppressed.handle([recovery()])
|
||||
assert suppressed.adapter.sent == []
|
||||
|
||||
sent = Env(FakeOllama())
|
||||
await sent.handle([problem()])
|
||||
sent.pipeline._gate = ReportGate(sent.repo.report_counts)
|
||||
await handle_later(sent, [recovery()])
|
||||
assert len(sent.adapter.sent) == 2
|
||||
|
||||
|
||||
async def test_recovery_without_any_history_is_sent():
|
||||
env = Env(FakeOllama())
|
||||
await env.handle([recovery()])
|
||||
assert len(env.adapter.sent) == 1
|
||||
|
||||
|
||||
async def test_recovery_switch_off_stores_but_does_not_send():
|
||||
env = Env(FakeOllama(), recovery_enabled=False)
|
||||
await env.handle([problem()])
|
||||
await env.handle([recovery()])
|
||||
assert len(env.adapter.sent) == 1
|
||||
stored = [a for a in env.repo.recent() if a["kind"] == "recovery"][0]
|
||||
assert stored["suppressed"] == 1 and stored["suppress_reason"] == SUPPRESS_RECOVERY_OFF
|
||||
|
||||
|
||||
async def test_reminder_goes_through_routing_and_is_stored_without_ai():
|
||||
env = Env(FakeOllama(), routes=StaticRoutes([rule(use_default_targets=True)]))
|
||||
reminder = Event(ts=1000, host="h1", service="disk", kind=KIND_REMINDER, state=2, output="voll",
|
||||
since_ts=1000 - 4 * 3600)
|
||||
await env.handle([reminder])
|
||||
assert "weiter CRIT seit 4 h: voll" in env.adapter.sent[0][1]
|
||||
assert env.ollama.calls == []
|
||||
assert env.repo.recent()[0]["kind"] == "reminder" and env.repo.recent()[0]["ai_status"] == "skipped"
|
||||
|
||||
|
||||
async def test_reminder_without_matching_rule_goes_to_default_targets():
|
||||
env = Env(FakeOllama(), routes=StaticRoutes([rule(match_host="db*", use_default_targets=True)]))
|
||||
await env.handle([Event(ts=1000, host="web01", service="disk", kind=KIND_REMINDER, state=2)])
|
||||
assert len(env.adapter.sent) == 1
|
||||
@@ -4,16 +4,16 @@ from app.models import (KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, SUPPRESS_DOW
|
||||
from app.poller import Poller, PollerStatus, StateStore, compute_events
|
||||
from app.poller.events import FLAP_QUIET_SECONDS
|
||||
|
||||
NOW = 10_000
|
||||
NOW = 100_000
|
||||
|
||||
|
||||
def svc(state=2, host="h1", service="cpu", hard=True, output="boom", changed=NOW - 100,
|
||||
def svc(state=2, host="h1", service="cpu", hard=True, output="boom", changed=NOW - 3600,
|
||||
downtime=False, flapping=False):
|
||||
return ServiceState(host, service, state, hard, output, changed, downtime, flapping)
|
||||
|
||||
|
||||
def run(previous, current, now=NOW, min_duration=0):
|
||||
events, rows = compute_events(previous, current, now, min_duration)
|
||||
events, rows = compute_events(previous, current, now, lambda *_: min_duration)
|
||||
return events, {row.key: row for row in rows}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from app.checkmk import ServiceState
|
||||
from app.models import KIND_PROBLEM, KIND_RECOVERY, STATE_OK, SUPPRESS_DOWNTIME
|
||||
from app.poller import compute_events
|
||||
from app.routing import ALL_KINDS, Route, delay_provider
|
||||
|
||||
NOW = 100_000
|
||||
DEFAULT_MINUTES = 15
|
||||
|
||||
|
||||
def svc(state=2, service="cpu", changed=NOW, downtime=False, output="boom", flapping=False):
|
||||
return ServiceState("h1", service, state, True, output, changed, downtime, flapping)
|
||||
|
||||
|
||||
def zpool_rule():
|
||||
return Route(1, "ZFS", 1, True, "*", "*zpool*", 1, ALL_KINDS, False, (), delay_minutes=0)
|
||||
|
||||
|
||||
def make_run(rules=()):
|
||||
provider = delay_provider(list(rules), DEFAULT_MINUTES)
|
||||
|
||||
def run(previous, current, now):
|
||||
events, rows = compute_events(previous, current, now, provider)
|
||||
return events, {row.key: row for row in rows}
|
||||
return run
|
||||
|
||||
|
||||
def test_problem_not_reported_before_delay_expires():
|
||||
run = make_run()
|
||||
events, rows = run({}, [svc(changed=NOW)], NOW + 899)
|
||||
assert events == [] and rows[("h1", "cpu")].reported_state == STATE_OK
|
||||
|
||||
|
||||
def test_problem_reported_after_delay_expires():
|
||||
run = make_run()
|
||||
_, rows = run({}, [svc(changed=NOW)], NOW + 30)
|
||||
events, rows = run(rows, [svc(changed=NOW)], NOW + 900)
|
||||
assert [(e.kind, e.state) for e in events] == [(KIND_PROBLEM, 2)]
|
||||
assert rows[("h1", "cpu")].reported_state == 2 and rows[("h1", "cpu")].reported_at == NOW + 900
|
||||
|
||||
|
||||
def test_zpool_immediately_other_service_after_15_minutes():
|
||||
run = make_run([zpool_rule()])
|
||||
current = [svc(state=1, service="zpool status", changed=NOW), svc(state=1, service="cpu", changed=NOW)]
|
||||
events, rows = run({}, current, NOW + 30)
|
||||
assert [e.service for e in events] == ["zpool status"]
|
||||
events, _ = run(rows, current, NOW + 900)
|
||||
assert [e.service for e in events] == ["cpu"]
|
||||
|
||||
|
||||
def test_worsening_restarts_the_delay():
|
||||
run = make_run()
|
||||
_, rows = run({}, [svc(state=1, changed=NOW)], NOW + 900)
|
||||
worse = [svc(state=2, changed=NOW + 900)]
|
||||
events, rows = run(rows, worse, NOW + 1000)
|
||||
assert [e.state for e in events] == []
|
||||
events, _ = run(rows, worse, NOW + 900 + 900)
|
||||
assert [e.state for e in events] == [2]
|
||||
|
||||
|
||||
def test_problem_gone_before_delay_gives_neither_alert_nor_recovery():
|
||||
run = make_run()
|
||||
_, rows = run({}, [svc(changed=NOW)], NOW + 60)
|
||||
events, rows = run(rows, [], NOW + 120)
|
||||
assert events == [] and rows == {}
|
||||
|
||||
|
||||
def test_downtime_during_wait_suppresses_event_at_expiry():
|
||||
run = make_run()
|
||||
_, rows = run({}, [svc(changed=NOW)], NOW + 60)
|
||||
events, _ = run(rows, [svc(changed=NOW, downtime=True)], NOW + 900)
|
||||
assert len(events) == 1 and events[0].suppressed and events[0].suppress_reason == SUPPRESS_DOWNTIME
|
||||
|
||||
|
||||
def test_downtime_ended_before_expiry_reports_normally_without_catch_up():
|
||||
run = make_run()
|
||||
_, rows = run({}, [svc(changed=NOW, downtime=True)], NOW + 60)
|
||||
events, rows = run(rows, [svc(changed=NOW)], NOW + 600)
|
||||
assert events == []
|
||||
events, _ = run(rows, [svc(changed=NOW)], NOW + 900)
|
||||
assert len(events) == 1 and not events[0].suppressed
|
||||
|
||||
|
||||
def test_problem_since_survives_warn_crit_changes():
|
||||
run = make_run([zpool_rule()])
|
||||
current = [svc(state=1, service="zpool", changed=NOW - 7200)]
|
||||
_, rows = run({}, current, NOW)
|
||||
assert rows[("h1", "zpool")].problem_since == NOW - 7200
|
||||
events, rows = run(rows, [svc(state=2, service="zpool", changed=NOW + 30)], NOW + 60)
|
||||
assert rows[("h1", "zpool")].problem_since == NOW - 7200
|
||||
assert events[0].problem_since_ts == NOW - 7200 and events[0].since_ts == NOW + 30
|
||||
|
||||
|
||||
def test_transition_event_carries_previous_state_and_duration():
|
||||
run = make_run([zpool_rule()])
|
||||
_, rows = run({}, [svc(state=1, service="zpool", changed=NOW - 7200)], NOW)
|
||||
events, _ = run(rows, [svc(state=2, service="zpool", changed=NOW)], NOW + 30)
|
||||
assert events[0].previous_state == 1 and events[0].previous_duration == 7200
|
||||
|
||||
|
||||
def test_previous_duration_known_although_delay_phase_updated_the_row():
|
||||
run = make_run()
|
||||
_, rows = run({}, [svc(state=1, changed=NOW - 7200)], NOW)
|
||||
_, rows = run(rows, [svc(state=2, changed=NOW)], NOW + 30)
|
||||
events, _ = run(rows, [svc(state=2, changed=NOW)], NOW + 900)
|
||||
assert events[0].previous_state == 1 and events[0].previous_duration == 7200
|
||||
|
||||
|
||||
def test_new_problem_has_no_previous_state():
|
||||
events, _ = make_run([zpool_rule()])({}, [svc(service="zpool", changed=NOW)], NOW)
|
||||
assert events[0].previous_state is None and events[0].problem_since_ts == NOW
|
||||
|
||||
|
||||
def test_recovery_event_carries_last_state_and_problem_start():
|
||||
run = make_run([zpool_rule()])
|
||||
_, rows = run({}, [svc(state=2, service="zpool", changed=NOW - 3600)], NOW)
|
||||
events, _ = run(rows, [], NOW + 600)
|
||||
assert [e.kind for e in events] == [KIND_RECOVERY]
|
||||
assert events[0].previous_state == 2 and events[0].problem_since_ts == NOW - 3600
|
||||
@@ -0,0 +1,164 @@
|
||||
from app.db import Database
|
||||
from app.models import KIND_REMINDER, StateRow
|
||||
from app.mutes import SOURCE_UI, Mute
|
||||
from app.poller import StateStore
|
||||
from app.reminders import ReminderWorker, due_reminders
|
||||
from app.routing import ALL_KINDS, Route
|
||||
|
||||
NOW = 1_000_000
|
||||
HOUR = 3600
|
||||
|
||||
|
||||
def row(state=2, reported=2, reported_at=NOW - 5 * HOUR, last_reminder=0, downtime=False, flapping=False,
|
||||
service="zpool status"):
|
||||
return StateRow("h1", service, state, "kaputt", NOW - 6 * HOUR, downtime, flapping, reported, False,
|
||||
NOW - 7 * HOUR, reported_at, NOW - 6 * HOUR, last_reminder)
|
||||
|
||||
|
||||
def rule(hours=4, host="*", position=1):
|
||||
return Route(position, f"r{position}", position, True, host, "", 1, ALL_KINDS, False, (), reminder_hours=hours)
|
||||
|
||||
|
||||
def due(rows, rules=(), mutes=(), default=0, now=NOW):
|
||||
return due_reminders(rows, list(rules), list(mutes), default, now)
|
||||
|
||||
|
||||
def test_reminder_when_interval_elapsed_since_report():
|
||||
(event,) = due([row()], [rule(4)])
|
||||
assert event.kind == KIND_REMINDER and event.state == 2 and event.output == "kaputt"
|
||||
assert event.since_ts == NOW - 6 * HOUR and event.problem_since_ts == NOW - 7 * HOUR
|
||||
|
||||
|
||||
def test_no_reminder_before_interval():
|
||||
assert due([row(reported_at=NOW - 3 * HOUR)], [rule(4)]) == []
|
||||
|
||||
|
||||
def test_last_reminder_counts_when_newer_than_report():
|
||||
assert due([row(last_reminder=NOW - 3 * HOUR)], [rule(4)]) == []
|
||||
assert len(due([row(last_reminder=NOW - 4 * HOUR)], [rule(4)])) == 1
|
||||
|
||||
|
||||
def test_only_crit_state_and_reported_crit():
|
||||
assert due([row(state=1, reported=1)], [rule(4)]) == []
|
||||
assert due([row(state=1, reported=2)], [rule(4)]) == []
|
||||
assert due([row(state=2, reported=1)], [rule(4)]) == []
|
||||
assert due([row(state=2, reported=0)], [rule(4)]) == []
|
||||
|
||||
|
||||
def test_no_reminder_during_downtime_or_flapping():
|
||||
assert due([row(downtime=True)], [rule(4)]) == []
|
||||
assert due([row(flapping=True)], [rule(4)]) == []
|
||||
|
||||
|
||||
def test_no_reminder_during_mute():
|
||||
mute = Mute(1, "h1", "*zpool*", NOW + HOUR, "", "admin", 0, SOURCE_UI)
|
||||
assert due([row()], [rule(4)], [mute]) == []
|
||||
expired = Mute(1, "h1", "*zpool*", NOW - 1, "", "admin", 0, SOURCE_UI)
|
||||
assert len(due([row()], [rule(4)], [expired])) == 1
|
||||
|
||||
|
||||
def test_smallest_rule_interval_wins_and_overrides_default():
|
||||
rules = [rule(8, position=1), rule(2, position=2)]
|
||||
assert len(due([row(reported_at=NOW - 2 * HOUR)], rules, default=6)) == 1
|
||||
assert due([row(reported_at=NOW - 5 * HOUR)], [rule(8)], default=2) == []
|
||||
|
||||
|
||||
def test_global_default_applies_without_rule_interval():
|
||||
assert due([row()], [], default=0) == []
|
||||
assert len(due([row()], [], default=4)) == 1
|
||||
assert len(due([row()], [rule(0)], default=4)) == 1
|
||||
|
||||
|
||||
def test_rule_for_other_host_does_not_apply():
|
||||
assert due([row()], [rule(4, host="db*")], default=0) == []
|
||||
|
||||
|
||||
class Env:
|
||||
def __init__(self, rules=(), mutes=(), default=0):
|
||||
self.db = Database(":memory:")
|
||||
self.state = StateStore(self.db)
|
||||
self.sent = []
|
||||
self.clock = [NOW]
|
||||
self.rules, self.mutes, self.default = list(rules), list(mutes), default
|
||||
self.worker = self.make_worker()
|
||||
|
||||
def make_worker(self):
|
||||
async def sink(events):
|
||||
self.sent.extend(events)
|
||||
|
||||
return ReminderWorker(self.state.load, lambda: self.rules, lambda now: self.mutes, lambda: self.default,
|
||||
sink, self.state.mark_reminded, clock=lambda: self.clock[0])
|
||||
|
||||
def store(self, state_row):
|
||||
self.state.apply({}, [state_row], NOW)
|
||||
|
||||
|
||||
async def test_worker_sends_and_persists_timestamp():
|
||||
env = Env([rule(4)])
|
||||
env.store(row())
|
||||
assert await env.worker.tick() == 1
|
||||
assert env.state.load()[("h1", "zpool status")].last_reminder_ts == NOW
|
||||
assert len(env.sent) == 1 and env.sent[0].kind == KIND_REMINDER
|
||||
|
||||
|
||||
async def test_worker_waits_full_interval_for_next_reminder():
|
||||
env = Env([rule(4)])
|
||||
env.store(row())
|
||||
await env.worker.tick()
|
||||
env.clock[0] = NOW + 3 * HOUR
|
||||
assert await env.worker.tick() == 0
|
||||
env.clock[0] = NOW + 4 * HOUR
|
||||
assert await env.worker.tick() == 1
|
||||
|
||||
|
||||
async def test_no_double_send_after_restart():
|
||||
env = Env([rule(4)])
|
||||
env.store(row())
|
||||
await env.worker.tick()
|
||||
env.sent.clear()
|
||||
restarted = env.make_worker()
|
||||
env.clock[0] = NOW + 60
|
||||
assert await restarted.tick() == 0 and env.sent == []
|
||||
|
||||
|
||||
async def test_worker_skips_muted_and_downtime_services():
|
||||
mute = Mute(1, "h1", "*", NOW + HOUR, "", "admin", 0, SOURCE_UI)
|
||||
env = Env([rule(4)], [mute])
|
||||
env.store(row())
|
||||
assert await env.worker.tick() == 0
|
||||
assert env.state.load()[("h1", "zpool status")].last_reminder_ts == 0
|
||||
|
||||
|
||||
async def test_poller_upsert_keeps_reminder_timestamp():
|
||||
env = Env([rule(4)])
|
||||
env.store(row())
|
||||
await env.worker.tick()
|
||||
previous = env.state.load()
|
||||
changed = previous[("h1", "zpool status")]
|
||||
from dataclasses import replace
|
||||
stale = replace(changed, output="neu", last_reminder_ts=0)
|
||||
env.state.apply(previous, [stale], NOW + 30)
|
||||
assert env.state.load()[("h1", "zpool status")].last_reminder_ts == NOW
|
||||
|
||||
|
||||
async def test_worker_error_in_sink_does_not_stop_run_loop():
|
||||
import asyncio
|
||||
env = Env([rule(4)])
|
||||
env.store(row())
|
||||
calls = []
|
||||
|
||||
async def failing_sink(events):
|
||||
calls.append(1)
|
||||
raise RuntimeError("pipeline kaputt")
|
||||
|
||||
async def sleep(seconds):
|
||||
if len(calls) >= 1:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
worker = ReminderWorker(env.state.load, lambda: env.rules, lambda now: [], lambda: 0, failing_sink,
|
||||
env.state.mark_reminded, clock=lambda: NOW, sleep=sleep)
|
||||
try:
|
||||
await worker.run()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
assert calls == [1]
|
||||
@@ -0,0 +1,124 @@
|
||||
from app.chat import Target
|
||||
from app.models import KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, KIND_REMINDER, Event
|
||||
from app.routing import (ALL_KINDS, Route, ai_allowed, ai_hints, delay_provider, reminder_interval,
|
||||
resolve_delay, resolve_loud, resolve_targets, route_matches)
|
||||
|
||||
TG = Target("telegram", "1")
|
||||
MX = Target("matrix", "!r:s")
|
||||
DEFAULTS = [TG, MX]
|
||||
|
||||
|
||||
def rule(position=1, **overrides):
|
||||
fields = dict(id=position, name=f"r{position}", position=position, enabled=True, match_host="",
|
||||
match_service="", min_state=1, kinds=ALL_KINDS, stop_on_match=False, targets=())
|
||||
fields.update(overrides)
|
||||
return Route(**fields)
|
||||
|
||||
|
||||
def event(kind=KIND_PROBLEM, state=2, output="boom", host="web01", service="disk"):
|
||||
return Event(ts=1, host=host, service=service, kind=kind, state=state, output=output)
|
||||
|
||||
|
||||
def test_output_contains_is_case_insensitive_substring():
|
||||
r = rule(output_contains="DEGRADED")
|
||||
assert route_matches(r, event(output="pool is degraded now"))
|
||||
assert not route_matches(r, event(output="pool online"))
|
||||
|
||||
|
||||
def test_output_contains_is_not_a_regex():
|
||||
r = rule(output_contains="a.c")
|
||||
assert route_matches(r, event(output="xa.cx"))
|
||||
assert not route_matches(r, event(output="abc"))
|
||||
|
||||
|
||||
def test_empty_output_filter_matches_everything():
|
||||
assert route_matches(rule(), event(output=""))
|
||||
|
||||
|
||||
def test_reminder_follows_problem_rules():
|
||||
only_problem = rule(kinds=(KIND_PROBLEM,), min_state=2)
|
||||
assert route_matches(only_problem, event(kind=KIND_REMINDER, state=2))
|
||||
assert not route_matches(only_problem, event(kind=KIND_REMINDER, state=1))
|
||||
assert not route_matches(rule(kinds=(KIND_RECOVERY,)), event(kind=KIND_REMINDER))
|
||||
|
||||
|
||||
def test_kinds_and_min_state_still_apply():
|
||||
r = rule(min_state=2, kinds=(KIND_PROBLEM, KIND_FLAPPING))
|
||||
assert not route_matches(r, event(state=1))
|
||||
assert not route_matches(r, event(kind=KIND_RECOVERY, state=0))
|
||||
assert route_matches(r, event(kind=KIND_FLAPPING, state=1))
|
||||
|
||||
|
||||
def test_delay_without_match_uses_default():
|
||||
assert resolve_delay([], "h", "s", 2, "", 15) == 15
|
||||
assert resolve_delay([rule(match_host="db*", delay_minutes=1)], "web01", "s", 2, "", 15) == 15
|
||||
|
||||
|
||||
def test_delay_rule_without_value_uses_default():
|
||||
assert resolve_delay([rule()], "h", "s", 2, "", 15) == 15
|
||||
|
||||
|
||||
def test_delay_smallest_wins_and_zero_overrides_default():
|
||||
rules = [rule(1, delay_minutes=10), rule(2, delay_minutes=0), rule(3, delay_minutes=5)]
|
||||
assert resolve_delay(rules, "h", "s", 2, "", 15) == 0
|
||||
assert resolve_delay([rule(1, delay_minutes=10), rule(2, delay_minutes=5)], "h", "s", 2, "", 15) == 5
|
||||
|
||||
|
||||
def test_delay_respects_service_glob_state_and_output():
|
||||
r = rule(match_service="*zpool*", min_state=1, output_contains="error", delay_minutes=0)
|
||||
assert resolve_delay([r], "h", "ZPool status", 1, "Some Error", 15) == 0
|
||||
assert resolve_delay([r], "h", "cpu", 1, "Some Error", 15) == 15
|
||||
assert resolve_delay([r], "h", "zpool", 1, "fine", 15) == 15
|
||||
assert resolve_delay([rule(min_state=2, delay_minutes=0)], "h", "s", 1, "", 15) == 15
|
||||
|
||||
|
||||
def test_delay_ignores_disabled_rules_and_honours_stop():
|
||||
assert resolve_delay([rule(enabled=False, delay_minutes=0)], "h", "s", 2, "", 15) == 15
|
||||
rules = [rule(1, delay_minutes=7, stop_on_match=True), rule(2, delay_minutes=0)]
|
||||
assert resolve_delay(rules, "h", "s", 2, "", 15) == 7
|
||||
|
||||
|
||||
def test_delay_provider_returns_seconds():
|
||||
provider = delay_provider([rule(match_service="zpool*", delay_minutes=0)], 15)
|
||||
assert provider("h", "zpool status", 1, "") == 0
|
||||
assert provider("h", "cpu", 1, "") == 900
|
||||
|
||||
|
||||
def test_ai_allowed_off_as_soon_as_one_matching_rule_disables():
|
||||
assert ai_allowed([], event())
|
||||
assert ai_allowed([rule(1), rule(2, match_host="db*", ai_enabled=False)], event())
|
||||
assert not ai_allowed([rule(1), rule(2, ai_enabled=False)], event())
|
||||
|
||||
|
||||
def test_resolve_loud():
|
||||
assert resolve_loud([], event())
|
||||
assert not resolve_loud([rule(loud=False)], event())
|
||||
assert resolve_loud([rule(1, loud=False), rule(2, loud=True)], event())
|
||||
assert resolve_loud([rule(1, loud=False, match_host="db*")], event())
|
||||
|
||||
|
||||
def test_ai_hints_in_rule_order_and_only_matching():
|
||||
rules = [rule(2, ai_hint="zweiter"), rule(1, ai_hint="erster"), rule(3, ai_hint="x", match_host="db*"),
|
||||
rule(4, ai_hint=" ")]
|
||||
assert ai_hints(rules, event()) == "erster\nzweiter"
|
||||
assert ai_hints([], event()) == ""
|
||||
|
||||
|
||||
def test_ai_hints_clipped_to_1200_characters():
|
||||
rules = [rule(1, ai_hint="a" * 800), rule(2, ai_hint="b" * 800)]
|
||||
text = ai_hints(rules, event())
|
||||
assert len(text) == 1200 and text.startswith("a" * 800) and text.endswith("b" * 399)
|
||||
|
||||
|
||||
def test_reminder_interval_smallest_positive_wins():
|
||||
assert reminder_interval([], event()) == 0
|
||||
assert reminder_interval([rule(1, reminder_hours=None), rule(2, reminder_hours=0)], event()) == 0
|
||||
assert reminder_interval([rule(1, reminder_hours=8), rule(2, reminder_hours=4), rule(3)], event()) == 4
|
||||
|
||||
|
||||
def test_use_default_targets_adds_defaults_to_explicit_targets():
|
||||
other = Target("telegram", "9")
|
||||
assert resolve_targets([rule(use_default_targets=True)], event(), DEFAULTS) == DEFAULTS
|
||||
assert resolve_targets([rule(targets=(other,), use_default_targets=True)], event(), DEFAULTS) == [other, TG, MX]
|
||||
assert resolve_targets([rule(targets=(other,))], event(), DEFAULTS) == [other]
|
||||
assert resolve_targets([rule()], event(), DEFAULTS) == []
|
||||
@@ -0,0 +1,60 @@
|
||||
import logging
|
||||
|
||||
from app.db import Database
|
||||
from app.models import KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY
|
||||
from app.routing import ZPOOL_MARKER, RouteRepository, seed_default_rules
|
||||
|
||||
|
||||
def test_zpool_rule_is_created_with_specified_fields():
|
||||
db = Database(":memory:")
|
||||
assert seed_default_rules(db) is True
|
||||
(rule,) = RouteRepository(db).list_all()
|
||||
assert rule.name == "ZFS Zpool" and rule.enabled
|
||||
assert (rule.match_host, rule.match_service, rule.min_state) == ("*", "*zpool*", 1)
|
||||
assert set(rule.kinds) == {KIND_PROBLEM, KIND_RECOVERY, KIND_FLAPPING}
|
||||
assert rule.use_default_targets and rule.targets == ()
|
||||
assert rule.delay_minutes == 0 and rule.ai_enabled and rule.reminder_hours == 4 and rule.loud
|
||||
assert "zpool status -v" in rule.ai_hint and "nicht verifiziert" in rule.ai_hint
|
||||
assert len(rule.ai_hint) <= 800
|
||||
|
||||
|
||||
def test_seed_runs_only_once():
|
||||
db = Database(":memory:")
|
||||
assert seed_default_rules(db) is True
|
||||
assert seed_default_rules(db) is False
|
||||
assert len(RouteRepository(db).list_all()) == 1
|
||||
|
||||
|
||||
def test_deleted_rule_does_not_come_back():
|
||||
db = Database(":memory:")
|
||||
seed_default_rules(db)
|
||||
repo = RouteRepository(db)
|
||||
repo.delete(repo.list_all()[0].id)
|
||||
assert seed_default_rules(db) is False
|
||||
assert repo.list_all() == []
|
||||
|
||||
|
||||
def test_seed_is_appended_after_existing_rules_and_audited():
|
||||
db = Database(":memory:")
|
||||
db.execute("INSERT INTO routes(name, position) VALUES ('alt', 7)")
|
||||
seed_default_rules(db)
|
||||
assert [r.name for r in RouteRepository(db).list_all()] == ["alt", "ZFS Zpool"]
|
||||
assert db.query("SELECT user, action FROM audit")[0]["action"] == "route.seed"
|
||||
assert db.query("SELECT 1 FROM app_state WHERE key=?", (ZPOOL_MARKER,))
|
||||
|
||||
|
||||
def test_seed_failure_is_logged_and_never_raises(caplog):
|
||||
class BrokenDb:
|
||||
def query(self, *args):
|
||||
raise RuntimeError("db kaputt")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
assert seed_default_rules(BrokenDb()) is False
|
||||
assert "Standardregel" in caplog.text
|
||||
|
||||
|
||||
def test_failed_write_leaves_no_marker_so_next_start_retries():
|
||||
db = Database(":memory:")
|
||||
db.execute("DROP TABLE routes")
|
||||
assert seed_default_rules(db) is False
|
||||
assert db.query("SELECT 1 FROM app_state WHERE key=?", (ZPOOL_MARKER,)) == []
|
||||
@@ -140,12 +140,15 @@ def test_matrix_test_message_without_config_reports_error(client, admin):
|
||||
|
||||
def test_alerting_section_saves_and_validates(client, app, admin):
|
||||
login_ok(client)
|
||||
form = {"poll_interval": "45", "poll_min_duration": "10", "alerts_bundle_seconds": "90",
|
||||
"alerts_ai_per_minute": "5"}
|
||||
form = {"poll_interval": "45", "alerts_default_delay_minutes": "10", "alerts_reminder_hours": "6",
|
||||
"alerts_recovery_enabled": "1", "alerts_bundle_seconds": "90", "alerts_ai_per_minute": "5"}
|
||||
assert post(client, "/settings/alerting", "/settings/alerting", **form).status_code == 303
|
||||
assert app.state.store.get_int("poll.interval", 30) == 45
|
||||
assert app.state.store.get_int("alerts.ai_per_minute", 10) == 5
|
||||
for override in ({"poll_interval": "1"}, {"poll_interval": "abc"}, {"poll_min_duration": "-3"},
|
||||
assert app.state.store.get_int("alerts.default_delay_minutes", 15) == 10
|
||||
assert app.state.store.get_int("alerts.reminder_hours", 0) == 6
|
||||
for override in ({"poll_interval": "1"}, {"poll_interval": "abc"}, {"alerts_default_delay_minutes": "-3"},
|
||||
{"alerts_default_delay_minutes": "1441"}, {"alerts_reminder_hours": "169"},
|
||||
{"alerts_bundle_seconds": "99999"}, {"alerts_ai_per_minute": "1.5"}):
|
||||
response = post(client, "/settings/alerting", "/settings/alerting", **{**form, **override})
|
||||
assert response.status_code == 422, override
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from tests.test_web import ADMIN, VIEWER, admin, app, client, csrf_from, login_ok, post, users, viewer # noqa: F401
|
||||
from tests.test_web_routing import enable_telegram, route_form, routes
|
||||
|
||||
HINT = "Geheimer Hinweis zum Pool"
|
||||
|
||||
|
||||
def rule_form(**overrides):
|
||||
data = route_form(delay_minutes="0", output_contains="degraded", ai_enabled="1", ai_hint=HINT,
|
||||
reminder_hours="4", loud="1", use_default_targets="1")
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def test_page_is_called_rules_and_shows_new_fields(client, admin):
|
||||
login_ok(client)
|
||||
page = client.get("/routing")
|
||||
assert "<h1>Regeln</h1>" in page.text and ">Regeln</a>" in page.text
|
||||
for label in ("Verzögerung in Minuten", "Ausgabe enthält", "KI-Analyse für diese Alarme", "KI-Hinweis",
|
||||
"Erinnerung alle N Stunden", "Laut", "Standardziele aller aktivierten Backends"):
|
||||
assert label in page.text, label
|
||||
|
||||
|
||||
def test_create_stores_all_new_fields(client, app, admin):
|
||||
enable_telegram(app)
|
||||
login_ok(client)
|
||||
assert post(client, "/routing", "/routing", **rule_form()).status_code == 303
|
||||
rule = routes(app)[0]
|
||||
assert rule.delay_minutes == 0 and rule.output_contains == "degraded" and rule.ai_enabled
|
||||
assert rule.ai_hint == HINT and rule.reminder_hours == 4 and rule.loud and rule.use_default_targets
|
||||
|
||||
|
||||
def test_unchecked_boxes_and_empty_numbers_mean_off_and_default(client, app, admin):
|
||||
login_ok(client)
|
||||
form = rule_form(delay_minutes="", reminder_hours="", output_contains="", ai_hint="")
|
||||
for flag in ("ai_enabled", "loud", "use_default_targets"):
|
||||
form.pop(flag)
|
||||
post(client, "/routing", "/routing", **form)
|
||||
rule = routes(app)[0]
|
||||
assert rule.delay_minutes is None and rule.reminder_hours is None
|
||||
assert not rule.ai_enabled and not rule.loud and not rule.use_default_targets
|
||||
|
||||
|
||||
def test_edit_page_prefills_values(client, app, admin):
|
||||
login_ok(client)
|
||||
post(client, "/routing", "/routing", **rule_form())
|
||||
page = client.get(f"/routing/{routes(app)[0].id}")
|
||||
assert 'name="delay_minutes"' in page.text and 'value="0"' in page.text
|
||||
assert HINT in page.text and 'value="degraded"' in page.text and 'value="4"' in page.text
|
||||
|
||||
|
||||
def test_new_fields_are_validated(client, app, admin):
|
||||
login_ok(client)
|
||||
for override in ({"delay_minutes": "-1"}, {"delay_minutes": "1441"}, {"delay_minutes": "abc"},
|
||||
{"delay_minutes": "1.5"}, {"reminder_hours": "169"}, {"reminder_hours": "-2"},
|
||||
{"output_contains": "x" * 201}, {"ai_hint": "y" * 801}, {"ai_hint": "a\x00b"}):
|
||||
response = post(client, "/routing", "/routing", **rule_form(**override))
|
||||
assert response.status_code == 422, override
|
||||
assert routes(app) == []
|
||||
|
||||
|
||||
def test_boundary_values_accepted(client, app, admin):
|
||||
login_ok(client)
|
||||
form = rule_form(delay_minutes="1440", reminder_hours="168", output_contains="x" * 200, ai_hint="y" * 800)
|
||||
assert post(client, "/routing", "/routing", **form).status_code == 303
|
||||
|
||||
|
||||
def test_audit_has_old_and_new_values_but_never_the_hint_text(client, app, admin):
|
||||
login_ok(client)
|
||||
post(client, "/routing", "/routing", **rule_form())
|
||||
rule = routes(app)[0]
|
||||
post(client, f"/routing/{rule.id}", f"/routing/{rule.id}", **rule_form(delay_minutes="5", ai_hint="Neuer geheimer Text"))
|
||||
entries = app.state.db.query("SELECT action, old_value, new_value FROM audit WHERE action LIKE 'route.%'")
|
||||
update = next(e for e in entries if e["action"] == "route.update")
|
||||
assert "delay=0" in update["old_value"] and "delay=5" in update["new_value"]
|
||||
assert f"hint_len={len(HINT)}" in update["old_value"] and "hint_len=19" in update["new_value"]
|
||||
assert all(HINT not in str(e[k]) and "Neuer geheimer" not in str(e[k]) for e in entries
|
||||
for k in ("old_value", "new_value"))
|
||||
|
||||
|
||||
def test_alerting_section_shows_new_fields_with_defaults(client, admin):
|
||||
login_ok(client)
|
||||
page = client.get("/settings/alerting")
|
||||
assert "Standard-Verzögerung in Minuten" in page.text and 'value="15"' in page.text
|
||||
assert "Recovery-Meldungen senden" in page.text and "checked" in page.text
|
||||
assert "Mindestdauer" not in page.text
|
||||
|
||||
|
||||
def test_alerting_section_saves_recovery_switch_and_reminder(client, app, admin):
|
||||
login_ok(client)
|
||||
form = {"poll_interval": "30", "alerts_default_delay_minutes": "0", "alerts_reminder_hours": "12",
|
||||
"alerts_bundle_seconds": "60", "alerts_ai_per_minute": "10"}
|
||||
assert post(client, "/settings/alerting", "/settings/alerting", **form).status_code == 303
|
||||
store = app.state.store
|
||||
assert store.get("alerts.recovery_enabled") == "0"
|
||||
assert store.get_int("alerts.default_delay_minutes", 15) == 0
|
||||
assert store.get_int("alerts.reminder_hours", 0) == 12
|
||||
post(client, "/settings/alerting", "/settings/alerting", alerts_recovery_enabled="1", **form)
|
||||
assert store.get("alerts.recovery_enabled") == "1"
|
||||
|
||||
|
||||
def add_state(app, service, state=2, reported=0, age=60, downtime=0):
|
||||
app.state.db.execute(
|
||||
"INSERT INTO service_states(host, service, state, output, last_state_change, in_downtime, "
|
||||
"reported_state) VALUES ('web01', ?, ?, 'x', ?, ?, ?)",
|
||||
(service, state, int(time.time()) - age, downtime, reported))
|
||||
|
||||
|
||||
def test_dashboard_shows_reporting_status(client, app, viewer):
|
||||
add_state(app, "wartend", age=60)
|
||||
add_state(app, "gemeldet", reported=2, age=7200)
|
||||
add_state(app, "downtime", downtime=1, age=7200)
|
||||
add_state(app, "stumm", age=7200)
|
||||
app.state.runtime.mutes.create("web01", "stumm", timedelta(hours=1), "test", "admin", "ui")
|
||||
login_ok(client, VIEWER)
|
||||
text = client.get("/").text
|
||||
assert "wartet noch 14 min" in text
|
||||
assert ">gemeldet<" in text
|
||||
assert "unterdrückt (Downtime)" in text and "unterdrückt (Stummschaltung)" in text
|
||||
|
||||
|
||||
def test_dashboard_waiting_time_uses_rule_delay(client, app, admin):
|
||||
login_ok(client)
|
||||
post(client, "/routing", "/routing", **rule_form(delay_minutes="30", match_host="web*", output_contains="",
|
||||
min_state="1"))
|
||||
add_state(app, "wartend", age=60)
|
||||
assert "wartet noch 29 min" in client.get("/").text
|
||||
Reference in new issue
Block a user